SSD Nodes Learn 8GB RAM — $66/yr
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-02

How to Run Your Own Shlink URL Shortener for VPS

Run Shlink 5.1 with Docker Compose on your VPS, connect DNS and Postgres, then add API keys, web client, QR codes and click stats for your short links.

Wetin you dey build

Self-hosted URL shortener na small server wey dey turn long link to short one wey you own, and dey count every click on am. Shlink na the one to choose: e be open source, e dey release as Docker image, and e dey do the complete 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. The API server dey answer redirects and hold the data. The 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 for here na the ones wey current as of July 2026: Shlink 5.1 and shlink-web-client 4.8.

Make short domain point to the server first

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

Create one DNS A record for the short domain, make e point 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 reach everywhere, and every later step go fail in confusing way, because dem no fit issue TLS (transport layer security) certificate for name wey no dey resolve.

Compose file

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

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

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

The first start run the database migrations, so e take longer pass later starts. When e settle, check say the 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 the API dey alive and the database connection dey work. A 500 here almost always mean say na database problem: the DB_PASSWORD for .env no match wetin Postgres create with, because the Postgres image dey read POSTGRES_PASSWORD only when e initialise empty data directory. If you edit the password later, e no get effect until you remove the volume and start again.

Terminate HTTPS for front

Shlink dey serve plain HTTP for port 8080. TLS suppose dey inside reverse proxy, and the setting wey matter 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 go return 404 responses, and visit stats go 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 complete walkthrough, including the renewal timer, dey for 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. Leave am false behind an HTTPS proxy, and every link wey the API send back go be an http:// link wey later redirect. This one dey cost one extra round trip and e dey look wrong for the web client.

Create an API key

Nothing fit talk to the API without key. Use the CLI inside the container generate one.

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

The command go print the key one time. Copy am now, because dem store am hashed and you no fit show am again. shlink api-key:list dey show the names and whether each key dey enabled, but e no dey show the key itself. Use shlink api-key:disable and the name revoke one.

Every REST call 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, dem disable am, or e don pass the expiry date.

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

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 dey print one row for each 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 wey Shlink dey use to download the GeoLite2 database. Without am, visits still dey recorded, but dem no go get location.

The web client and QR codes

The web client dey for 127.0.0.1:8081 now, and e need im own proxy entry. You fit use an SSH tunnel too if you no wan publish am. E go ask for server URL and API key the first time e load. 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 another person.

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 empty space around the code for pixels, and the complete image size na the size plus two times the margin. Add errorCorrection=Q for code wey go still scan when dem print am small or part of am dey covered.

Make am continue to run

Shortener fit fail quietly. 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, no be home page, and alert for anything wey no be redirect. One Uptime Kuma instance wey you host yourself dey 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. Upgrades na sudo docker compose pull followed by sudo docker compose up -d, and Shlink go 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 the domain for the Host header. If proxy send e own name, or internal address, Shlink go look for that code under domain wey no get links, so e go answer 404. Set proxy_set_header Host $host; inside nginx location block and reload the proxy. The links go start to work immediately, and you no need restart the container.

I need Postgres, or SQLite dey enough?

SQLite dey okay if you wan test Shlink, and e no need another container. Move to Postgres before you publish important links, because visit rows dey increase with every click and SQLite dey serialise writes. If you switch later, you go need export and re-import your links. So, choosing Postgres from the beginning go save you that migration.

I fit recover API key wey I forget to copy?

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

Why country columns for my visit stats dey empty?

Geolocation need the GeoLite2 database. Shlink go download am only when you give am a GEOLITE_LICENSE_KEY. The key free from MaxMind. Add am to the environment section, recreate the container, and new visits go get location. Visits wey happen 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 into 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