Shlink is the established, API-first option in the open-source URL shortener space.
It is not trying to be Dub’s full cloud-native attribution platform, and it is not a single dashboard app like Kutt or Snapp. Shlink is a backend URL shortener with a strong REST API, a CLI, visit tracking, custom domains, optional geolocation, and a separate browser-based web client.
That split is important when self-hosting it: the Shlink backend is the source of truth, and the web UI connects to it with an API key.
What is Shlink?
Shlink is a PHP-based self-hosted URL shortener.
I inspected commit:
4a8fee47d48bcb949d6a5729ef32994f1f41464e
The tested Docker image reported:
5.1.7
What You Get
Shlink covers the core URL-shortener workflow well:
- branded short links under your own domain;
- generated or custom slugs;
- REST API for integrations;
- CLI commands for administration;
- API keys;
- tags;
- visit tracking;
- QR codes;
- optional GeoLite2-based geolocation;
- support for several SQL databases;
- optional Redis, RabbitMQ, Mercure, and Matomo integrations;
- a separate Shlink Web Client for browser-based management.
The big difference from Kutt and Snapp is that the UI is not built into the backend container.
Architecture
The current Shlink backend is a PHP application served by RoadRunner in the official Docker image.
The relevant stack is:
- PHP 8.4/8.5 application code.
- RoadRunner as the HTTP runtime in the Docker image.
- Mezzio middleware/application structure.
- Doctrine ORM and migrations for persistence.
- MariaDB, MySQL, PostgreSQL, Microsoft SQL Server, or SQLite as database options.
- GeoLite2 for optional location enrichment.
- Redis optionally for shared locks, cache, and real-time pub/sub related paths.
- RabbitMQ, Mercure, and Matomo as optional advanced integrations.
The Home-Lab setup in this post uses MariaDB:
Browser/API client -> Shlink Web Client or direct API calls
-> Shlink backend on RoadRunner
-> MariaDB
Redirect traffic hits the Shlink backend directly. The web client is only the management interface.
UI Model
Yes, Shlink has a UI, but it is separate.
You can either:
- use the hosted web client at
app.shlink.io; - self-host
shlinkio/shlink-web-client; - consume the REST API directly;
- manage the instance with the CLI.
The web client is a browser app. It stores the configured Shlink server URL and API key in the browser unless you preconfigure servers for it.
That preconfiguration path deserves care. A servers.json file mounted into the web-client container is readable by the browser, so putting an API key there can expose that key to anyone who can access that web client. For a private home-lab UI that may be acceptable; for a public UI, I prefer configuring the server manually in the browser and keeping API keys out of static files.
Home-Lab Compose Review
There was already a Shlink folder in Home-Lab, but it needed cleanup.
The old setup had a few issues:
- hardcoded database passwords;
- a hardcoded GeoLite license key;
- a hardcoded LAN domain;
- private host paths like
/home/docker/shlink; - duplicate compose files;
- web-client configuration that encouraged mounting
servers.jsonwith secrets.
I replaced that with a single canonical compose, moved secrets into .env, removed private host paths, skipped GeoLite2 by default, and left the web client unconfigured so API keys are not baked into the served static app.
Self-Hosting with Docker
The reusable Home-Lab compose file is here:
The site includes the same Compose file from the local snippets folder:
assets/snippets/shlink/docker-compose.yml
Pre-Requisites - Docker
Install Docker on your system before proceeding:
- Linux: Official Docker Engine install guide
- Windows / Mac: Docker Desktop
Verify installation: docker --version && docker compose version
version: "3"
services:
shlink:
image: shlinkio/shlink:stable
restart: always
container_name: shlink-backend
environment:
- TZ="America/Denver"
- DEFAULT_DOMAIN=192.168.1.66:8987 #no http/https. no trailing slash
- IS_HTTPS_ENABLED=false
- GEOLITE_LICENSE_KEY=Ea2FHWtkMx2q5MIN #we'll need to get this key from maxmind.com
- DB_DRIVER=maria
- DB_USER=shlink
- DB_NAME=shlink
- DB_PASSWORD=password #change this
- DB_HOST=database
depends_on:
- database
ports:
- 8987:8080
database:
image: mariadb:10.8
restart: always
container_name: shlink-database
environment:
- MARIADB_ROOT_PASSWORD=password #change this
- MARIADB_DATABASE=shlink
- MARIADB_USER=shlink
- MARIADB_PASSWORD=password #change this
volumes:
- /home/docker/shlink:/var/lib/mysql
shlink-web-client:
image: shlinkio/shlink-web-client
restart: always
container_name: shlink-gui
volumes:
- /home/docker/shlink/servers.json:/usr/share/nginx/html/servers.json #this file will be generated automatically
depends_on:
- shlink
ports:
- 8081:80
# version: "3.8"
# services:
# shlink:
# image: shlinkio/shlink:latest
# container_name: shlink
# environment:
# - DB_DRIVER=maria
# - DB_HOST=db
# - DB_PORT=3306
# - DB_USER=shlink
# - DB_PASSWORD=shlink_password
# - DB_NAME=shlink_db
# - SHORT_DOMAIN_HOST=shlink.example.com
# - SHORT_DOMAIN_SCHEMA=https
# - DEFAULT_SHORT_CODES_LENGTH=6
# - SHORT_CODES_CHARSET=alphanumeric
# - GEOLITE_LICENSE_KEY=YOUR_MAXMIND_LICENSE_KEY # Optional, for geolocation
# depends_on:
# db:
# condition: service_started
# ports:
# - "8080:8080"
# restart: unless-stopped
# db:
# image: mariadb:10.6
# container_name: shlink-db
# environment:
# MYSQL_ROOT_PASSWORD: root_password
# MYSQL_DATABASE: shlink_db
# MYSQL_USER: shlink
# MYSQL_PASSWORD: shlink_password
# volumes:
# - shlink_db_data:/var/lib/mysql
# restart: unless-stopped
# volumes:
# shlink_db_data:Prepare the environment:
cd assets/snippets/shlink
cp .env.sample .env
Generate database secrets:
openssl rand -base64 32
openssl rand -base64 32
Use those generated values for:
MARIADB_ROOT_PASSWORD
MARIADB_PASSWORD
Start the stack:
docker compose up -d
The default local URLs are:
http://localhost:8987 Shlink backend, API, and redirects
http://localhost:8988 Shlink Web Client
Domain Configuration
DEFAULT_DOMAIN must be the short-link hostname without protocol.
Local examples:
DEFAULT_DOMAIN=localhost:8987
DEFAULT_DOMAIN=192.168.1.2:8987
IS_HTTPS_ENABLED=false
Public examples:
DEFAULT_DOMAIN=s.example.com
IS_HTTPS_ENABLED=true
For public deployment, put Shlink behind a reverse proxy:
https://s.example.com -> reverse proxy -> http://shlink:8080
The web client can live on another hostname, for example:
https://shlink.example.com -> reverse proxy -> http://shlink-web-client:8080
API Keys
The Shlink backend requires an API key for management actions.
You have two practical options:
- Set
SHLINK_INITIAL_API_KEYin.envbefore the first boot. - Generate a key after the backend is running:
docker compose exec shlink shlink api-key:generate
Treat the API key as a secret.
Do not commit it into .env, servers.json, screenshots, or blog snippets.
GeoLite2
The compose file skips the initial GeoLite2 download by default:
SKIP_INITIAL_GEOLITE_DOWNLOAD=true
GEOLITE_LICENSE_KEY=
That means Shlink can run without a MaxMind account, but visitor geolocation will be limited.
To enable geolocation:
SKIP_INITIAL_GEOLITE_DOWNLOAD=false
GEOLITE_LICENSE_KEY=your-maxmind-key
The important operational detail is that the GeoLite key is a secret-like credential. Keep it in .env, not in the compose file.
Field Test
I smoke-tested the fixed Home-Lab compose with disposable containers and volumes.
Command shape:
SHLINK_PORT=8987 \
SHLINK_WEB_PORT=8988 \
DEFAULT_DOMAIN=localhost:8987 \
MARIADB_ROOT_PASSWORD=test-root-password \
MARIADB_PASSWORD=test-db-password \
docker compose -f /home/jalcocert/Desktop/Home-Lab/shlink/docker-compose.yml -p shlink_smoke up -d
Observed:
- MariaDB 11.4 became healthy.
- Shlink initialized the database.
- Shlink applied database updates.
- Shlink generated proxies and cleared entity cache.
- RoadRunner started successfully.
GET /rest/v3/healthreturned HTTP200.- The health body reported Shlink version
5.1.7. GET /on the web client returned HTTP200.
Then I removed the smoke-test containers and volumes:
docker compose -p shlink_smoke down -v --remove-orphans
Shlink vs Kutt vs Snapp vs Dub
Shlink is the mature API-first option.
Compared with Kutt:
- Shlink has a stronger CLI/API-first identity.
- Kutt has an integrated web app.
- Shlink’s web client is separate.
Compared with Snapp:
- Shlink is more established.
- Snapp has a newer SvelteKit/Postgres app model.
- Shlink is a better fit when you want backend stability and API-driven workflows.
Compared with Dub:
- Shlink is much lighter to self-host.
- Dub is a SaaS-grade attribution platform with specialized cloud-native data layers.
- Shlink is closer to a traditional app: backend, SQL database, optional extras.
See also:
FAQ
Does Shlink have a UI?
Yes, but the UI is separate from the backend. Use the hosted Shlink Web Client, self-host shlinkio/shlink-web-client, use the REST API, or use the CLI.
Does Shlink require MariaDB?
No. Shlink supports MariaDB, MySQL, PostgreSQL, Microsoft SQL Server, and SQLite. The Home-Lab snippet uses MariaDB because it is a good durable default for a long-running service.
Can I run it with SQLite only?
Yes. The official Docker image can run standalone with SQLite. For a persistent home-lab service, I prefer an external database container so backups and upgrades are clearer.
Does Shlink require Redis?
No. Redis is optional. Add it only when you want the Redis-backed cache, lock, or pub/sub paths.
What additional services can Shlink use?
The Home-Lab snippet only needs Shlink, MariaDB, and the optional Shlink Web Client.
Shlink can also integrate with extra services:
MariaDB/MySQL/PostgreSQL/Microsoft SQL Server/SQLite
Redis
RabbitMQ
Mercure Hub
Matomo
GeoLite2 / MaxMind database download
Shlink Web Client
Those are not all required.
- SQL database: required for persistence unless you use the built-in SQLite path. MariaDB, MySQL, PostgreSQL, and SQLite are open source and self-hostable. Microsoft SQL Server is supported but not the usual FOSS home-lab choice.
- Redis: optional. Open source and self-hostable; useful for cache, locks, and pub/sub related paths.
- RabbitMQ: optional. Open source and self-hostable; useful for event/message integrations.
- Mercure Hub: optional. Open source and self-hostable; useful for real-time updates.
- Matomo: optional. Open source and self-hostable; useful if you want to forward visit analytics into Matomo.
- GeoLite2 / MaxMind: optional for geolocation. The database is not a self-hosted service; you download the GeoLite2 data with a MaxMind license key.
- Shlink Web Client: optional. Open source and self-hostable; it is only the management UI, not the redirect backend.
Does Shlink require GeoLite2?
Not for basic link shortening. The Docker docs present GEOLITE_LICENSE_KEY as part of the basic setup, but the current entrypoint can skip the initial GeoLite download with SKIP_INITIAL_GEOLITE_DOWNLOAD=true. The provided compose does that by default.
Can I access it from my LAN?
Yes. Publish the backend port and set:
DEFAULT_DOMAIN=192.168.1.2:8987
IS_HTTPS_ENABLED=false
Then open:
http://192.168.1.2:8987
Use:
http://192.168.1.2:8988
for the self-hosted web client if you keep the default ports.
Should I preconfigure the web client with an API key?
Only for private, trusted deployments. If you mount servers.json into the web client, that file is readable by browsers. For anything public, configure the server manually in your browser and keep the API key out of static files.
What is the simplest Shlink setup?
Backend only with SQLite.
The Home-Lab snippet is more operationally explicit:
Shlink backend + MariaDB + Shlink Web Client
That gives you durable database persistence and a UI while still staying much lighter than Dub.
Verdict
Shlink is a strong self-hosted URL shortener when you care about API access, CLI administration, custom domains, and mature backend behavior.
For a home lab, I would run it with MariaDB and the separate web client as shown here. Keep GeoLite2 optional, keep API keys out of static web-client config, and put the public short domain behind your normal reverse proxy.
Comments