Skip to content
Go back

Shlink vs YOURLS: Self-Hosted Short Links

By KingPin 13 min read
Shlink vs YOURLS: Self-Hosted Short Links
Contents

You paste a 200-character tracking URL into a chat and it wraps across four lines. You shorten it with a free hosted service. Two years later the free tier is gone, the dashboard is behind a paywall, and every link you ever handed out depends on a company’s mood.

A self-hosted shortener fixes both problems. You get links on your own domain, like s.example.com/docs, and the redirect table lives in a database you control. Two projects dominate this space: Shlink and YOURLS.

My pick for a home-labber is Shlink. It is API-first, it runs well in Docker, it handles several domains from one instance, and its visit analytics are better out of the box. YOURLS wins in one case: you want a classic PHP app with a built-in admin page and a plugin for everything, and you do not care about an API-first design. The rest of this post shows both running, then defends the pick.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/shlink-vs-yourls

Architecture: Two Very Different Animals

Shlink is a PHP application that does not run under Apache or PHP-FPM in the Docker image. It runs on RoadRunner, a long-lived Go application server that keeps PHP workers in memory. Older Shlink releases also supported Swoole and OpenSwoole, but Shlink 4.0 dropped them and recommends RoadRunner.

Shlink has no UI of its own. It exposes a REST API and a CLI. The web interface is a separate project, shlink-web-client, a static app served by nginx. Your browser talks to the Shlink API directly, which has one practical consequence: the server URL you give the web client must be reachable from your browser, not just from inside the Docker network.

The database can be SQLite (the default), MySQL, MariaDB, PostgreSQL, or Microsoft SQL Server. Pick one with DB_DRIVER.

Shlink is at 5.1.8 as of November 2026. Version 5.0 was a cleanup release. It dropped PHP 8.3 (the code needs PHP 8.4 or newer), and it removed the built-in /qr-code URL. It also stopped guessing which proxies sit in front of it. If you have read a 4.x tutorial, keep that list in mind. I checked every variable below against the 5.x source.

YOURLS

YOURLS (Your Own URL Shortener) is a classic PHP and MySQL application. One codebase serves the redirects, the admin pages, and a small HTTP API. The official Docker image is yourls, and the 1.10.6 release ships in apache, fpm, and fpm-alpine variants. MySQL or MariaDB is required. There is no SQLite option in the official image.

The admin UI is a server-rendered PHP page. You log in, paste a URL, get a short link. It looks like 2009 and works like 2009. A plugin system hooks into almost every step, and that is YOURLS’s real strength.

Shlink 5.1YOURLS 1.10
RuntimePHP 8.4+ on RoadRunnerPHP on Apache or FPM
DatabaseSQLite, MySQL, MariaDB, Postgres, MSSQLMySQL or MariaDB
Admin UISeparate web clientBuilt in
APIREST, first-classSingle endpoint, token or password
Multiple domainsBuilt inNot built in
Visit analyticsBuilt in, per visitClick counts, plugins for more

This stack runs Shlink, PostgreSQL, and the web client. Image tags are pinned.

compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: shlink
POSTGRES_USER: shlink
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- shlink-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shlink -d shlink"]
interval: 5s
timeout: 3s
retries: 10
shlink:
image: shlinkio/shlink:5.1.8
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:${SHLINK_PORT:-8080}:8080"
environment:
DEFAULT_DOMAIN: ${DEFAULT_DOMAIN}
IS_HTTPS_ENABLED: ${IS_HTTPS_ENABLED}
GEOLITE_LICENSE_KEY: ${GEOLITE_LICENSE_KEY:-}
INITIAL_API_KEY: ${INITIAL_API_KEY}
DB_DRIVER: postgres
DB_HOST: db
DB_NAME: shlink
DB_USER: shlink
DB_PASSWORD: ${DB_PASSWORD}
web-client:
image: shlinkio/shlink-web-client:4.9.0
restart: unless-stopped
depends_on:
- shlink
ports:
- "127.0.0.1:${WEB_CLIENT_PORT:-8000}:8080"
environment:
SHLINK_SERVER_URL: ${SHLINK_SERVER_URL}
SHLINK_SERVER_API_KEY: ${INITIAL_API_KEY}
SHLINK_SERVER_NAME: Shlink
volumes:
shlink-db:

The variables that matter:

Note that the web client image bakes the API key into a static config file that any visitor can read. That is fine on 127.0.0.1. On a public hostname, put it behind authentication or leave the key out and enter it in the browser instead.

Start it and check health.

Terminal window
docker compose up -d
curl localhost:8080/rest/health
{"status":"pass","version":"5.1.8","links":{"about":"https://shlink.io","project":"https://github.com/shlinkio/shlink"}}

YOURLS with Docker Compose

compose.yml
services:
db:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_DATABASE: yourls
MARIADB_USER: yourls
MARIADB_PASSWORD: ${DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
volumes:
- yourls-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 3s
retries: 20
yourls:
image: yourls:1.10.6-apache
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "127.0.0.1:${YOURLS_PORT:-8081}:8080"
environment:
YOURLS_SITE: ${YOURLS_SITE}
YOURLS_USER: ${YOURLS_USER}
YOURLS_PASS: ${YOURLS_PASS}
YOURLS_DB_HOST: db
YOURLS_DB_NAME: yourls
YOURLS_DB_USER: yourls
YOURLS_DB_PASS: ${DB_PASSWORD}
volumes:
yourls-db:

Here is a gotcha I hit. Plenty of older YOURLS tutorials map the host port to container port 80. The 1.10.6-apache image listens on 8080 inside the container, so a :80 mapping gives you “connection reset by peer” and a confusing hour. Map to 8080.

Open /admin/ once and click “Install YOURLS”. That creates the tables. Until then, the site root returns 403, which is normal.

YOURLS_SITE is the full public URL. YOURLS builds every short link from it, so set it to your real domain before you share links.

The API takes an X-Api-Key header. Short URLs live under /rest/v3/short-urls.

Terminal window
curl -s -X POST localhost:8080/rest/v3/short-urls \
-H "X-Api-Key: $INITIAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"longUrl":"https://example.com/some/very/long/path?utm_source=newsletter","customSlug":"demo","tags":["docs"]}'

The response includes "shortUrl": "http://localhost:8080/demo" and a visitsSummary object. Confirm the redirect:

Terminal window
curl -I localhost:8080/demo
HTTP/1.1 302 Found
Location: https://example.com/some/very/long/path?utm_source=newsletter

Shlink answers with a 302 by default, so browsers do not cache the redirect and you can edit the target later. The REDIRECT_STATUS_CODE variable changes that.

The CLI is in the container as shlink. It is handy for scripts and for rescuing yourself when the API key is lost.

Terminal window
docker compose exec shlink shlink api-key:generate --name=laptop
docker compose exec shlink shlink short-url:create https://example.com/cli -c cli1

The second command prints Generated short URL: http://localhost:8080/cli1. In 5.x, short-url:create takes -t or --tag once per tag, not a comma-separated --tags list. api-key:disable takes the key name now, not the key itself.

Open the web client port in a browser. It shows a list of short URLs with tags, visit counts, and a create form. It is a React app and it is good. It also generates QR codes, which matters because Shlink itself stopped doing that in 5.0.

YOURLS: admin and API

In the admin UI you paste a URL and optionally type a keyword. For scripts, YOURLS exposes yourls-api.php. You authenticate with a username and password, or with a signature token that you copy from the Tools page in the admin UI. The signature avoids putting your password in cron jobs.

Terminal window
curl -s "http://localhost:8081/yourls-api.php" \
-d "signature=$YOURLS_SIGNATURE" \
-d "action=shorturl" -d "format=json" \
-d "url=https://example.com/long/path" -d "keyword=demo"
{"status":"success", ... "shorturl":"http:\/\/localhost:8081\/demo"}

YOURLS redirects with a 301 by default, which browsers cache hard. Plan your keywords before you share them. The action=url-stats call returns a click count for the link.

Analytics and Visits

Shlink records every visit as its own row: timestamp, referrer, user agent, whether it looks like a bot, and location. You can read them through the API, the CLI, or the web client charts. When I ran curl -I against a link, no visit appeared. A plain curl GET registered one, flagged as a bot, which is what you want from a tracker.

Location needs GeoLite2 data. Shlink downloads it using a MaxMind license key. You create a free MaxMind account, generate a license key, and pass it as GEOLITE_LICENSE_KEY. Without the key, Shlink runs fine and visits simply have no country or city. If you do not want IP-based tracking at all, look at DISABLE_IP_TRACKING and ANONYMIZE_REMOTE_ADDR.

Run docker compose exec shlink shlink visit:locate to backfill locations after you add a key.

YOURLS keeps click counts and basic stats per link. Each click is logged with a time and referrer. The admin stats page is fine for “how many people clicked the newsletter link.” For anything deeper, expect to install plugins or pipe data elsewhere.

Multiple Domains

Shlink treats domains as a first-class field. One instance can serve s.example.com, go.example.org, and anything else you point at it. Pass domain when you create a link:

Terminal window
curl -s -X POST localhost:8080/rest/v3/short-urls \
-H "X-Api-Key: $INITIAL_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"longUrl":"https://example.com/two","domain":"go.example.org","customSlug":"two"}'

Then list them:

Terminal window
docker compose exec shlink shlink domain:list
| Domain | Is default |
| localhost:8080 | Yes |
| go.example.org | No |

YOURLS serves one site, set by YOURLS_SITE. I have not found multi-domain support in core, and it is not a documented feature. If you need two domains with YOURLS, run two instances. For a business with several brands, that is the point where Shlink wins outright.

QR Codes

Shlink 4.x had a /qr-code suffix on short links. Shlink 5.0 removed it. QR codes now come from the web client, which renders them in the browser, or from an external service. If a script of yours fetches /qr-code, it breaks on 5.x.

YOURLS has no QR feature in the default install that I verified. Look in the plugin directory if you need one.

Reverse Proxy Notes

Both apps listen on plain HTTP and expect a proxy to add TLS. A Caddy setup is short:

s.example.com {
reverse_proxy shlink:8080
}

Three things to check:

  1. Set IS_HTTPS_ENABLED=true in Shlink (its default, which the example .env overrides for localhost), and YOURLS_SITE=https://... in YOURLS, so generated links use https.
  2. Shlink 5.x does not detect trusted proxies. With one proxy in front you need nothing extra. With more than one (say Cloudflare plus Caddy), set TRUSTED_PROXIES to a comma-separated list of proxy IPs or to the number of proxies. Skip this and your analytics show your proxy’s IP for everyone.
  3. Do not publish the web client on a public hostname without auth. It holds an API key.

If you run a proxy in Docker, join the proxy network instead of publishing ports to the host. See the Caddy vs Traefik comparison if you have not picked a proxy yet.

Backups: It Is Just a Database

Neither app keeps links on disk outside its database. Back up the database and you have backed up everything. Shlink with Postgres:

Terminal window
docker compose exec -T db pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gz

YOURLS with MariaDB:

Terminal window
docker compose exec -T db mariadb-dump -u yourls -p"$DB_PASSWORD" yourls | gzip > yourls-$(date +%F).sql.gz

Put the dump on a timer and ship it off the box. The Postgres backup guide covers the heavier options. Your 2 AM self will thank you for testing one restore before the day you need it. Do not forget .env and your plugin folder, because the dump does not hold those.

A Quick Word on Kutt

Kutt is the third name you will see. It is a Node.js shortener with a polished UI, user accounts, and custom domains. The 3.x line (3.2.6 as of July 2026) defaults to SQLite in its example config, with Postgres and MySQL as options through DB_CLIENT. Redis is optional.

Kutt makes sense if you want a hosted-style experience with signups for other people. For one admin with scripts and several domains, Shlink has the better API and YOURLS has the longer history. I did not run Kutt for this post, so treat that as a pointer, not a review.

The Verdict

Run Shlink.

Pick YOURLS if any of these are true. You already run shared PHP hosting and want zero containers. You want the oldest, simplest mental model: one app, one database, one admin page. You rely on a specific plugin that has no Shlink equivalent. YOURLS is fine for that. It is the forklift you already own, and it moves the couch.

The cost of Shlink is two moving parts instead of one, the separate web client, and a MaxMind signup if you want geography. That is a good trade for a home lab.

Common Questions

Yes, Shlink needs a database, but not a separate server. The default DB_DRIVER is SQLite, which stores everything in a file inside the container. For anything beyond a test, use PostgreSQL, MySQL, or MariaDB so you can dump and restore with standard tools.

Can YOURLS use multiple domains?

No, core YOURLS serves one site URL, set through YOURLS_SITE. The documented route for a second domain is a second instance with its own database. Shlink assigns a domain to each short link and serves all of them from one instance.

No, Shlink runs without a MaxMind account. The GEOLITE_LICENSE_KEY variable only feeds country and city lookups. Leave it empty and visits still record time, referrer, user agent, and bot status. You can add the key later and run shlink visit:locate to fill in old visits.

Yes, Shlink ships a YOURLS importer. Install the Shlink import plugin in YOURLS, then run shlink short-url:import yourls and answer its prompts for the YOURLS URL and credentials. The importer copies short URLs and their visits. Geolocation does not come across, because YOURLS never recorded it.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
PrivateBin vs Hemmelig: Secrets That Burn
Next Post
K8s Requests vs Limits in a Home Lab

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts