SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-29

How to Set Up Shlink URL Shortener with Docker

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

Wetin you dey build

Self-hosted URL shortener na small server wey dey turn long link to short link wey na your own, and dey count every click on am. Shlink na the one to choose: e be open source, e dey come as Docker image, and e dey handle the whole work with one container plus database. This guide go put am for VPS behind real short domain, with HTTPS, API key, QR codes, and click stats.

Two parts dey make am feel like commercial shortener. API server dey answer redirects and keep the data. Web client na separate static app wey dey talk to that API from your browser. You fit run both, or run only the API and control am from command line.

The version numbers here na the ones wey current as of July 2026: Shlink 5.1 and shlink-web-client 4.8.

Point short domain go the server first

The domain na the product. s.example.com/abc123 na the link wey people go see, so choose short one before you install anything. Shlink dey store the domain with every short URL, and if you change am later, every link wey you don share before go stop to work.

Create one DNS A record for the short domain, and point am to your VPS public IPv4 address. Add AAAA record too if the server get IPv6. Then confirm say e dey resolve before you continue.

dig +short s.example.com A

The output suppose be your server address. If e empty, the record never propagate yet. Every later step go fail for confusing way, because dem no fit issue TLS (transport layer security) certificate for name wey no resolve.

The compose file

Shlink need database. SQLite fit work for test, but Postgres na the correct choice for anything wey you plan to keep, because visit rows go dey accumulate and Postgres dey handle indexes and concurrent writes better. Put this for /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 dey bind to 127.0.0.1, so nothing fit reach from internet until reverse proxy for the next section dey ready. Docker dey write im own forwarding rules before host firewall, meaning say plain 8080:8080 line go expose the app even for machine wey firewall look closed. Binding to loopback address dey avoid that. This same pattern apply to any app wey you run this way, and guide to Docker Compose on a VPS explain am in more detail.

Database password dey come from .env file wey dey beside compose file, so e no go enter 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 am and monitor as API dey come up.

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

The first start dey run database migrations, so e dey take longer pass later starts. When e settle, check say service dey answer locally.

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

A 200 mean say API dey alive and database connection dey work. A 500 for here almost always mean database problem: DB_PASSWORD for .env no match wetin Postgres use create am, because Postgres image dey read POSTGRES_PASSWORD only when e dey initialise empty data directory. If you edit password later, e no go change anything until you remove volume and start again.

Terminate HTTPS for front

Shlink dey serve plain HTTP for port 8080. Reverse proxy suppose handle TLS, and the main setting na to pass the original host name through. Shlink dey decide which domain short code belong to by reading the Host header. So, if proxy rewrite am, links wey dey exist fit return 404, and visit stats fit attach to 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, dey inside Certbot guide for nginx on Ubuntu 24.04.

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

IS_HTTPS_ENABLED: "true" for the compose file na wetin make Shlink print https:// inside the short URLs wey e return. E no enable TLS by itself. If you leave am as false behind HTTPS proxy, every link wey the API return go be http:// link wey go redirect later. This add one round trip and e go look wrong for the web client.

Create API key

API no fit talk to anything without key. Generate one through the CLI inside the container.

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

The command go print the key once. Copy am now, because dem store am as hash and e no fit show again. shlink api-key:list dey show the names and whether each key dey enabled, but e no dey show the key itself. Revoke one with shlink api-key:disable and the name.

Every REST call dey carry the key inside an X-Api-Key header.

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

A JSON object wey get shortUrls key mean say the key dey work. A 401 wey carry INVALID_API_KEY mean say the key wrong, disabled, or e don pass the expiry date.

CLI na the fastest way to create links, and na the option wey scripts fit use 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 go give you readable link instead of generated code. Slugs dey unique for each domain, so if you try use slug wey don already dey taken, e go fail instead of silently overwriting the first link. You fit repeat --tag, and tags na how you group links wey you go later need combined stats for.

List wetin dey exist, then check traffic for one link.

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

short-url:visits go print one row for every click, with the date, referrer, and user agent. Country and city columns go remain empty unless you set GEOLITE_LICENSE_KEY environment variable. Na free MaxMind key be this, and Shlink dey use am to download GeoLite2 database. Without am, visits still dey record, but system no go locate dem.

The web client and QR codes

The web client don dey for 127.0.0.1:8081 now, and e need im own proxy entry. If you no wan publish am, you fit use SSH tunnel instead. When you first load am, e go ask for server URL and API key. Enter https://s.example.com and the key wey you generate. The client dey keep both for browser storage and e dey call your API directly, so no data dey pass through anybody else. Separating the interface from the API na pattern wey worth noticing, because na the same pattern wey let Halcyon dress Jellyfin library like 1990s rental store without changing the media server behind am.

QR codes no need any configuration. Add /qr-code to any short URL, and the API go return the image.

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

size na the width for pixels, and e accept 50 to 1000, with 300 as the default. format na png or svg. margin na the quiet space around the code for pixels, and the final image size na the size plus twice the margin. Add errorCorrection=Q for code wey still fit scan when dem print am small or cover part of am.

Make e dey run

A shortener fit fail quietly. The links go stop redirecting and nobody go tell you, because the person wey click am assume say the link don die. Point uptime check to real short URL instead of home page, and alert whenever response no be redirect. One self-hosted Uptime Kuma instance fit do this well, and e fit watch for specific status code.

Back up the database, no be the container. One command go dump am.

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

That file plus your compose file fit rebuild the whole service for new server. Every app for the server need its own version of that pair. Photo library na the awkward case, because PhotoPrism and Immich both keep the original files for disk and rows for database, so dump alone no go restore anything. Upgrades na sudo docker compose pull followed by sudo docker compose up -d, and Shlink dey run any new migrations when e start. Take the dump before you pull, because migration no fit roll back.

FAQ

Shlink dey match short code against domain for Host header. If proxy send its own name, or internal address, Shlink go search for that code under domain wey no get links, so e go return 404. Set proxy_set_header Host $host; inside nginx location block, then reload proxy. The links go start work immediately, and you no need restart container.

I need Postgres, or SQLite dey enough?

SQLite dey okay to test Shlink, and e no need second container. Move go Postgres before you publish important links, because visit rows dey increase with every click and SQLite dey serialize writes. If you switch later, you go need export and import your links again, so choosing Postgres from the start go save you that migration.

I fit recover API key wey I forget copy?

No. Shlink dey store hash of the key, so api-key:list fit show names and status but e no go ever show the value. Generate replacement with shlink api-key:generate, paste am inside web client, then disable the old one with shlink api-key:disable so e stop working.

Why country columns for my visit stats dey empty?

Geolocation need GeoLite2 database. Shlink go download am only when you give am GEOLITE_LICENSE_KEY. MaxMind dey provide the key free. Add am to environment section, recreate container, and new visits go get location information. Visits wey dem record before that go remain blank until you run shlink visit:locate.

Keep the domain and move the data. Dump the database with pg_dump, copy the dump and compose file go the new server, start the stack, then restore the dump inside the empty database before real traffic arrive. Change the DNS record last. The short codes and their visit history go remain, because everything dey inside the database.

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