Self-host your own URL shortener with Shlink and Docker
Shlink is the de-facto self-hosted URL shortener in the PHP world — MIT-licensed,
actively maintained by Alejandro Celaya (acelaya), and packaged as a tidy Docker
image that handles 99% of the operational pain for you. This post walks through
a 2026-ready Docker Compose stack that gets you from zero to a working
shortener, complete with a web UI, GeoLite2 geolocation, and HTTPS, in about
ten minutes.
TL;DR. Spin up
shlinkio/shlink:stable, drop ashlink-installerone-shot
container next to it, attach MariaDB + Redis, point a short domain at the
host, and front the whole thing with Caddy or Traefik for HTTPS. Total
moving parts: seven YAML services and one.envfile.
What is Shlink — and why host your own
Shlink is a PHP service (built on Mezzio / Laminas, ORM-mapped via Doctrine,
running on RoadRunner) that turns long URLs into short ones. You POST a long URL
to its REST API, get a short code in return, and Shlink will 30x-redirect
visitors from <your-short-domain>/abc12 to the real destination, while logging
the visit (IP, user-agent, referrer, country).
The active release line is 5.x. As of writing, the current image tag is
5.1.5 (stable and latest both point at it; tagged 2026-07-03 by
acelaya). The image is 107 MB on amd64, 98 MB on arm64, and runs as non-root
since v4.0.0 — no more -non-root / -alpine suffix gymnastics.
Compared to the popular hosted options:
- Bitly / TinyURL — fine for casual use, but you don’t own the data, you
don’t control the redirect speed at scale, and historical link data can be
deleted by the provider without notice. - YOURLS — the venerable PHP self-host. Still works, but the data model is
MySQL-only and the UI is decidedly 2008-era. - Shlink — modern REST API, multi-DB (MariaDB / MySQL / PostgreSQL /
MSSQL), QR code generation (deprecated as of 4.5.0, see § Pitfalls), full
OpenAPI spec, optional React PWA web client.
The Docker stack — a complete compose
The canonical stack has seven services. Here’s the minimum viable shape, taken
straight from shlinkio/shlink‘s own docker-compose.yml and the
shlinkio/shlink-docker reference repo:
services:
shlink:
image: shlinkio/shlink:stable
restart: unless-stopped
depends_on:
mariadb:
condition: service_healthy
redis:
condition: service_healthy
env_file: .env
networks: [shlink-net]
shlink-installer:
image: shlinkio/shlink:stable
depends_on:
mariadb:
condition: service_healthy
env_file: .env
entrypoint: shlink-installer
command: init --no-interaction
networks: [shlink-net]
shlink-web-client:
image: shlinkio/shlink-web-client
restart: unless-stopped
depends_on:
- shlink
environment:
SHLINK_SERVER_URL: ${SHLINK_SERVER_URL}
SHLINK_SERVER_API_KEY: ${INITIAL_API_KEY}
networks: [shlink-net]
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: shlink
MARIADB_USER: shlink
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
networks: [shlink-net]
redis:
image: redis:7-alpine
networks: [shlink-net]
networks:
shlink-net:
driver: bridge
volumes:
mariadb_data:Two things worth calling out:
shlinkandshlink-installerare the same image, invoked differently.
The installer runsshlink-installer init --no-interactiononce, which waits
for MariaDB to be reachable, applies migrations, downloads GeoLite2, then
exits 0. This is intentional —docker compose upshould not block.shlink-web-clientis optional but highly recommended. It’s a React PWA
(nginx:alpine base, 25 MB image) and reads its server connection details
from the two env vars shown above.
The .env file that makes it run
Shlink’s configuration is almost entirely environment-driven. The minimum
working .env is:
# Brand
DEFAULT_DOMAIN=s.example.com
IS_HTTPS_ENABLED=true
SHORT_URL_TRAILING_SLASH=false
# Database (use 'maria' for MariaDB, NOT 'mariadb' — common typo)
DB_DRIVER=maria
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=shlink
DB_USER=shlink
DB_PASSWORD=please-use-a-real-secret
# Visit tracking & rate-limit
REDIS_SERVERS=redis://redis:6379
# First-run API key (ignored after first start)
INITIAL_API_KEY=$(openssl rand -hex 32)
# GeoLite2 (free, required for visit geolocation since 2019-12-30)
GEOLITE_LICENSE_KEY=your-maxmind-license-key
# Web client config (consumed by shlink-web-client, not shlink)
SHLINK_SERVER_URL=https://s.example.comThree settings that look like typos but aren’t:
DB_DRIVER=maria— for MariaDB.mariadbis silently wrong (matches no
driver). For MySQL usemysql, PostgreSQLpostgres, MSSQLmssql,
SQLitesqlite.REDIS_SERVERSaccepts a comma-separated list — useful when you scale to
multiple Shlink instances and want a clustered Redis. v5.0.0 also adds
unix:/path/to/redis.sockfor local Unix sockets.- Any env var
Xcan be supplied asX_FILEpointing to a file (Docker
secrets). This is the only safe way to passDB_PASSWORDandINITIAL_API_KEY
in production.
GeoLite2 — the one annoying config
Since 2019-12-30, MaxMind requires a (free) license key to download
GeoLite2. Shlink reads it as GEOLITE_LICENSE_KEY and downloads the database
on first start.
To get a key:
- Sign up at https://www.maxmind.com/en/geolite2/signup.
- Generate a license key — answer No to “Will you be using geoipupdate?”
(Shlink does the download itself). - Set
GEOLITE_LICENSE_KEY=<key>in.envand wait ~30 minutes for key
propagation across MaxMind’s edge. (Yes, this is annoying. It is what it
is.) - Restart the
shlinkservice (docker compose up -d shlink). The next visit
will trigger a GeoLite2 download.
If you forget to set this, Shlink still works — visits are tracked, but
country / city stay Unknown. Annoying when you actually want analytics.
Creating an API key
Authentication to Shlink’s REST API is via X-Api-Key: <uuid> header. There
is no admin user — every API key is created explicitly. Three ways to mint one:
# 1. Set INITIAL_API_KEY in .env before first start. Creates one key on first
# boot. Ignored on subsequent starts.
# 2. After the stack is up, create one via the CLI inside the container:
docker compose exec shlink shlink api-key:create --name default
# -> Prints a UUID to stdout.
# 3. Or via REST API (requires an existing key with author_apis=ALL):
curl -X POST https://s.example.com/rest/v3/api-keys
-H "X-Api-Key: $EXISTING_KEY"
-H "Content-Type: application/json"
-d '{"name":"secondary"}'The web UI cannot create keys — by Shlink’s design, it only consumes them. If
you lose the API key, the only recovery is docker compose exec shlink shlink api-key:create --name replacement.
First short URL via the API
With INITIAL_API_KEY in hand, shorten your first URL:
KEY=$(grep INITIAL_API_KEY .env | cut -d= -f2)
curl -X POST https://s.example.com/rest/v3/short-urls
-H "X-Api-Key: $KEY"
-H "Content-Type: application/json"
-d '{
"longUrl": "https://github.com/shlinkio/shlink",
"tags": ["github","docs"],
"title": "Shlink on GitHub"
}'Response:
{
"shortCode": "gh5h2",
"shortUrl": "https://s.example.com/gh5h2",
"longUrl": "https://github.com/shlinkio/shlink",
"tags": ["github","docs"],
"title": "Shlink on GitHub"
}Hit https://s.example.com/gh5h2 and you land on GitHub. Hit
https://s.example.com/rest/health and you get a 200 (sanity check that the
service is up).
HTTPS — terminator lives in front, never in Shlink
The Shlink Docker image speaks plain HTTP on port 8080. It does not do
TLS termination. Put one of these in front:
- Caddy —
s.example.com { reverse_proxy shlink:8080 }. Auto-cert via Let’s
Encrypt. - Traefik — labels-based config, also auto-cert. Slightly more YAML.
- nginx + certbot — works, requires manual
certbot certonly+ renew cron. - Cloudflare Tunnel — zero cert config on your side; Cloudflare handles
it. Good fit if you’re already on Cloudflare.
Two env-var gotchas once HTTPS is in place:
IS_HTTPS_ENABLED=truetells Shlink to emithttps://short URLs (the
default if unset ishttp://).- If you have multiple proxies in front (Cloudflare → Caddy → Shlink), set
TRUSTED_PROXIES(since v4.5.0) — a comma-separated list of IP ranges so
Shlink can resolve the real client IP for visit logging.
Pitfalls (2026 edition)
A handful of things will burn you on first deploy. Saved you the second
Google search:
- Don’t use SQLite in production. The official docs explicitly warn
against it: “Using it in production is not supported, even less if mounted
via docker volumes.” Move to MariaDB / Postgres / MSSQL before going public. latestvsstable.latestpoints to alpha/beta releases when one
exists;stablealways tracks the latest stable. For production, pin to
a specific version (e.g.5.1.5) orstable.shlink-installer exited with code 0is not an error. The first-time
init runs the schema, migrations, ORM proxy generation, GeoLite2 download,
then exits 0. Checkdocker compose logs shlink-installerfor what it
actually did.- Web-client env vars go on the web-client container, not the installer.
SHLINK_SERVER_URL/SHLINK_SERVER_API_KEYconfigure the nginx-served
React app’s pre-populated server list. If you set them onshlink, nothing
happens. - Upgrades need a manual
db:migrate. The installer only runs on cold
start. Bump the image tag →docker compose pull→docker compose up -d
→docker compose exec shlink shlink db:migrate. - Redis is required if you run multiple Shlink instances. Shlink uses
symfony/lockfor shared state; without Redis, locks are local to each
instance and concurrent domain/key creation races.
What’s in your control vs not
You own:
- All URL-to-short-code mappings.
- All visit logs (IP hashes by default — set
ANONYMIZE_REMOTE_ADDR=falseto
disable, which makes your instance non-GDPR-compliant). - The full Redis / MariaDB state. You can
docker compose exec mariadb mysqldump
and walk away with a complete backup at any time.
You don’t own:
- The short domain itself — that’s a DNS concern. Point an A/AAAA at the
host running Shlink and you’re done. - The certificate — that’s the reverse proxy’s job (or Cloudflare’s).
TL;DR
For a single-user / small-team deployment, shlinkio/shlink:stable +
mariadb:11 + redis:7-alpine + a Caddy reverse proxy is a rock-solid 2026
setup. The official images are well-maintained, the API is clean, and visit
analytics actually work once you wire up GeoLite2. Total images: four.
Moving parts in compose.yaml: ten YAML lines per service.
Sources
- Project: https://github.com/shlinkio/shlink (5,137 stars, MIT, default
branchdevelop) - Docker image: https://hub.docker.com/r/shlinkio/shlink
- Documentation: https://shlink.io/documentation/
- Reference Docker stack: https://github.com/shlinkio/shlink-docker
- Web client: https://github.com/shlinkio/shlink-web-client
Comments