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.

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.json with 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
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:

  1. Set SHLINK_INITIAL_API_KEY in .env before the first boot.
  2. 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/health returned HTTP 200.
  • The health body reported Shlink version 5.1.7.
  • GET / on the web client returned HTTP 200.

Then I removed the smoke-test containers and volumes:

docker compose -p shlink_smoke down -v --remove-orphans

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

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.

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.

No. Redis is optional. Add it only when you want the Redis-backed cache, lock, or pub/sub paths.

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.

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.

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.