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 a shlink-installer one-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 .env file.

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:

Yaml
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:

  • shlink and shlink-installer are the same image, invoked differently.
    The installer runs shlink-installer init --no-interaction once, which waits
    for MariaDB to be reachable, applies migrations, downloads GeoLite2, then
    exits 0. This is intentional — docker compose up should not block.
  • shlink-web-client is 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:

Bash
# 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.com

Three settings that look like typos but aren’t:

  • DB_DRIVER=maria — for MariaDB. mariadb is silently wrong (matches no
    driver). For MySQL use mysql, PostgreSQL postgres, MSSQL mssql,
    SQLite sqlite.
  • REDIS_SERVERS accepts 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.sock for local Unix sockets.
  • Any env var X can be supplied as X_FILE pointing to a file (Docker
    secrets). This is the only safe way to pass DB_PASSWORD and INITIAL_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:

  1. Sign up at https://www.maxmind.com/en/geolite2/signup.
  2. Generate a license key — answer No to “Will you be using geoipupdate?”
    (Shlink does the download itself).
  3. Set GEOLITE_LICENSE_KEY=<key> in .env and wait ~30 minutes for key
    propagation across MaxMind’s edge. (Yes, this is annoying. It is what it
    is.)
  4. Restart the shlink service (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:

Bash
# 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:

Bash
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:

Json
{
  "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:

  • Caddys.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=true tells Shlink to emit https:// short URLs (the
    default if unset is http://).
  • 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.
  • latest vs stable. latest points to alpha/beta releases when one
    exists; stable always tracks the latest stable. For production, pin to
    a specific version (e.g. 5.1.5) or stable.
  • shlink-installer exited with code 0 is not an error. The first-time
    init runs the schema, migrations, ORM proxy generation, GeoLite2 download,
    then exits 0. Check docker compose logs shlink-installer for what it
    actually did.
  • Web-client env vars go on the web-client container, not the installer.
    SHLINK_SERVER_URL / SHLINK_SERVER_API_KEY configure the nginx-served
    React app’s pre-populated server list. If you set them on shlink, nothing
    happens.
  • Upgrades need a manual db:migrate. The installer only runs on cold
    start. Bump the image tag → docker compose pulldocker compose up -d
    docker compose exec shlink shlink db:migrate.
  • Redis is required if you run multiple Shlink instances. Shlink uses
    symfony/lock for 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=false to
    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

Last modified: 2026年7月16日

Author

Comments

Write a Reply or Comment

Your email address will not be published.