Your Links Are Ugly, and Someone Else Owns Them
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
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.1 | YOURLS 1.10 | |
|---|---|---|
| Runtime | PHP 8.4+ on RoadRunner | PHP on Apache or FPM |
| Database | SQLite, MySQL, MariaDB, Postgres, MSSQL | MySQL or MariaDB |
| Admin UI | Separate web client | Built in |
| API | REST, first-class | Single endpoint, token or password |
| Multiple domains | Built in | Not built in |
| Visit analytics | Built in, per visit | Click counts, plugins for more |
Shlink with Docker Compose
This stack runs Shlink, PostgreSQL, and the web client. Image tags are pinned.
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:
DEFAULT_DOMAINis the short domain, such ass.example.com. Shlink puts it in every link it returns.IS_HTTPS_ENABLEDdefaults totruein Shlink, so links come out ashttps://. The example.envsets it tofalsefor plain-HTTP testing on localhost. Flip it back totrueonce a reverse proxy terminates TLS, or Shlink keeps handing outhttp://links.INITIAL_API_KEYcreates an API key on first boot. This saves you a CLI step on a fresh install.GEOLITE_LICENSE_KEYis the MaxMind GeoLite2 key used for country and city lookups. More on it below.
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.
docker compose up -dcurl 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
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.
Creating Links
Shlink: REST API
The API takes an X-Api-Key header. Short URLs live under /rest/v3/short-urls.
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:
curl -I localhost:8080/demoHTTP/1.1 302 FoundLocation: https://example.com/some/very/long/path?utm_source=newsletterShlink 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.
Shlink: CLI
The CLI is in the container as shlink. It is handy for scripts and for rescuing yourself when the API key is lost.
docker compose exec shlink shlink api-key:generate --name=laptopdocker compose exec shlink shlink short-url:create https://example.com/cli -c cli1The 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.
Shlink: web client
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.
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:
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:
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:
- Set
IS_HTTPS_ENABLED=truein Shlink (its default, which the example.envoverrides for localhost), andYOURLS_SITE=https://...in YOURLS, so generated links usehttps. - 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_PROXIESto 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. - 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:
docker compose exec -T db pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gzYOURLS with MariaDB:
docker compose exec -T db mariadb-dump -u yourls -p"$DB_PASSWORD" yourls | gzip > yourls-$(date +%F).sql.gzPut 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.
- You get an API from day one, so shell scripts, n8n flows, and browser extensions all work the same way.
- Multi-domain is built in.
- Per-visit analytics with bot flagging come in the box.
- The Docker image is the supported install path, not an afterthought.
- Upgrades are image tag bumps. Migrations run on startup.
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
Does Shlink need a database?
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.
Do I need a MaxMind account to run Shlink?
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.
Can I move from YOURLS to Shlink later?
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.