SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Self-hosted URL shortener with Shlink

Run your own URL shortener on a VPS with Shlink and Docker Compose: short domain DNS, Postgres, API keys, the web client, QR codes and click stats.

What you are building

A self-hosted URL shortener is a small server that turns a long link into a short one you own, and counts every click on it. Shlink is the one to pick: it is open source, it ships as a Docker image, and it does the whole job in one container plus a database. This guide puts it on a VPS behind a real short domain, with HTTPS, an API key, QR codes and click stats.

Two pieces make it feel like a commercial shortener. The API server answers redirects and holds the data. The web client is a separate static app that talks to that API from your browser. You can run both, or run the API alone and drive it from the command line.

The version numbers here are what was current as of July 2026: Shlink 5.1 and shlink-web-client 4.8.

Point a short domain at the server first

The domain is the product. s.example.com/abc123 is the link people see, so pick something short and choose it before you install anything. Shlink stores the domain with every short URL, and changing it later means every link you already handed out stops working.

Create one DNS A record for the short domain, pointing at your VPS public IPv4 address. Add an AAAA record too if the server has IPv6. Then confirm it resolves before you continue.

dig +short s.example.com A

The output must be your server's address. If it is empty, the record has not propagated yet, and every later step will fail in a confusing way, because a TLS (transport layer security) certificate cannot be issued for a name that does not resolve.

The compose file

Shlink needs a database. SQLite works for a test, but Postgres is the right choice for anything you plan to keep, because visit rows accumulate and Postgres handles the indexes and the concurrent writes better. Put this in /opt/shlink/compose.yaml.

services:
  shlink:
    image: shlinkio/shlink:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      DEFAULT_DOMAIN: s.example.com
      IS_HTTPS_ENABLED: "true"
      DB_DRIVER: postgres
      DB_HOST: database
      DB_NAME: shlink
      DB_USER: shlink
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      - database

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

  web-client:
    image: shlinkio/shlink-web-client:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:8080"

volumes:
  shlink_db:

Both published ports bind to 127.0.0.1, so nothing is reachable from the internet until the reverse proxy in the next section is in place. Docker writes its own forwarding rules ahead of the host firewall, which means a plain 8080:8080 line would expose the app even on a box whose firewall looks closed. Binding to the loopback address avoids that. The same pattern applies to any app you run this way, and it is covered in more detail in the guide to Docker Compose on a VPS.

The database password comes from a .env file next to the compose file, so it never lands in the YAML.

sudo mkdir -p /opt/shlink
printf 'DB_PASSWORD=%s\n' "$(openssl rand -base64 24)" | sudo tee /opt/shlink/.env
sudo chmod 600 /opt/shlink/.env

Start it and watch the API come up.

cd /opt/shlink
sudo docker compose up -d
sudo docker compose logs -f shlink

The first start runs the database migrations, so it takes longer than later ones. When it settles, check that the service answers locally.

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/rest/health

A 200 means the API is alive and the database connection works. A 500 here is almost always the database: the DB_PASSWORD in .env does not match what Postgres was created with, because the Postgres image reads POSTGRES_PASSWORD only when it initialises an empty data directory. Editing the password later has no effect until you remove the volume and start again.

Terminate HTTPS in front of it

Shlink serves plain HTTP on port 8080. TLS belongs in a reverse proxy, and the one setting that matters is passing the original host name through. Shlink decides which domain a short code belongs to by reading the Host header, so a proxy that rewrites it produces 404 responses on links that exist, and visit stats attached to the wrong domain.

server {
    server_name s.example.com;
    listen 80;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Then issue the certificate. The full walkthrough, including the renewal timer, is in the Certbot guide for nginx on Ubuntu 24.04.

sudo certbot --nginx -d s.example.com

IS_HTTPS_ENABLED: "true" in the compose file is what makes Shlink print https:// in the short URLs it returns. It does not enable TLS by itself. Leave it false behind an HTTPS proxy and every link the API hands back is an http:// link that then redirects, which costs a round trip and looks wrong in the web client.

Create an API key

Nothing can talk to the API without a key. Generate one through the CLI inside the container.

sudo docker compose exec shlink shlink api-key:generate --name "web client"

The command prints the key once. Copy it now, because it is stored hashed and cannot be shown again. shlink api-key:list shows the names and whether each key is enabled, never the key itself. Revoke one with shlink api-key:disable and the name.

Every REST call carries the key in an X-Api-Key header.

curl -H "X-Api-Key: YOUR_KEY" https://s.example.com/rest/v3/short-urls

A JSON object with a shortUrls key means the key works. A 401 carrying INVALID_API_KEY means the key is wrong, disabled, or past its expiry date.

The CLI is the fastest way to make links, and it is the one that scripts well.

sudo docker compose exec shlink shlink short-url:create https://example.com/a/very/long/path
sudo docker compose exec shlink shlink short-url:create https://example.com/docs --custom-slug docs --tag reference

--custom-slug gives you a readable link instead of a generated code. Slugs are unique per domain, so a second attempt on a slug that is taken fails instead of silently overwriting the first link. --tag can be repeated, and tags are how you group links you will want combined stats for later.

List what exists, then look at one link's traffic.

sudo docker compose exec shlink shlink short-url:list
sudo docker compose exec shlink shlink short-url:visits docs

short-url:visits prints one row per click with the date, the referrer and the user agent. The country and city columns stay empty unless you set a GEOLITE_LICENSE_KEY environment variable, which is a free MaxMind key Shlink uses to download the GeoLite2 database. Without it, visits are still recorded, they are simply not located.

The web client and QR codes

The web client is now on 127.0.0.1:8081 and needs its own proxy entry, or an SSH tunnel if you would rather not publish it. It asks for a server URL and an API key on first load. Enter https://s.example.com and the key you generated. The client keeps both in browser storage and calls your API directly, so no data passes through anyone else.

QR codes need no configuration at all. Append /qr-code to any short URL and the API returns the image.

https://s.example.com/docs/qr-code?size=500&format=svg&margin=20

size is the width in pixels and accepts 50 to 1000, with 300 as the default. format is png or svg. margin is the quiet space around the code in pixels, and the finished image measures the size plus twice the margin. Add errorCorrection=Q for a code that still scans when it is printed small or partly covered.

Keep it running

A shortener fails quietly. The links stop redirecting and nobody tells you, because the person who clicked assumed the link was dead. Point an uptime check at a real short URL rather than at the home page, and alert on anything that is not a redirect. A self-hosted Uptime Kuma instance does this well, and it can watch for a specific status code.

Back up the database, not the container. One command dumps it.

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

That file plus your compose file rebuilds the whole service on a new server. Upgrades are sudo docker compose pull followed by sudo docker compose up -d, and Shlink runs any new migrations on start. Take the dump before you pull, because a migration cannot be rolled back.

FAQ

Shlink matches a short code against the domain in the Host header. A proxy that sends its own name, or an internal address, makes Shlink look for that code under a domain that has no links, so it answers 404. Set proxy_set_header Host $host; in the nginx location block and reload the proxy. The links start working straight away, with no container restart.

Do I need Postgres, or is SQLite enough?

SQLite is fine for trying Shlink out and needs no second container. Move to Postgres before you publish links that matter, because visit rows grow with every click and SQLite serialises writes. Switching later means exporting and re-importing your links, so choosing Postgres at the start saves you that migration.

Can I recover an API key I forgot to copy?

No. Shlink stores a hash of the key, so api-key:list shows names and status but never the value. Generate a replacement with shlink api-key:generate, paste it into the web client, then disable the old one with shlink api-key:disable so it stops working.

Why are the country columns empty in my visit stats?

Geolocation needs the GeoLite2 database, which Shlink downloads only when you give it a GEOLITE_LICENSE_KEY. The key is free from MaxMind. Add it to the environment section, recreate the container, and new visits get located. Visits recorded before that stay blank until you run shlink visit:locate.

Keep the domain and move the data. Dump the database with pg_dump, copy the dump and the compose file to the new server, start the stack, then restore the dump into the empty database before real traffic arrives. Change the DNS record last. The short codes and their visit history survive, because everything lives in the database.

#shlink#url-shortener#self-hosting#docker#postgres