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.
Wetín you dey build
A self-hosted URL shortener na small server wey dey turn long link into short one wey you own, and dey count every click on am. Shlink na the one to choose: e be open source, e dey ship as Docker image, and e dey do the complete work for one container plus database. This guide 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 keep 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 October 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 AThe 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 dey increase 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 dey bind to 127.0.0.1, so nothing fit reach am from internet until you set up the reverse proxy for the next section. Docker dey write im own forwarding rules before the host firewall rules, meaning plain 8080:8080 line fit expose the app even for machine wey firewall look closed. Binding to loopback address dey prevent that. 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 .env file wey dey 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/.envStart am and monitor as the API come up.
cd /opt/shlink
sudo docker compose up -d
sudo docker compose logs -f shlinkThe first start dey run database migrations, so e dey 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/healthA 200 mean say the API dey alive and the database connection dey work. A 503 mean say Shlink dey run but the database check fail. The health endpoint dey answer 503 for any database error, never 500. If curl print 000 instead, nothing dey answer for the port: the container stop during im start-up checks and Docker dey restart am, so read the logs above. Either way, the usual cause na say DB_PASSWORD for .env no match wetin Postgres create am 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. 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.comIS_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
Nothing fit talk to the API 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 one time. Copy am now, because dem store am hashed and you no fit show am again. shlink api-key:list shows the names and whether each key dey enabled, but e never show the key itself. Revoke one with shlink api-key:disable and the name.
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-urlsA JSON object wey get shortUrls key means the key dey work. A 401 wey get type as https://shlink.io/api/error/invalid-api-key, with Invalid API key as the title, means the key wrong, disabled, or e don pass the expiry date.
Command line dey create short links
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 docsshort-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 dey now for 127.0.0.1:8081 and e need im own proxy entry, or an SSH tunnel if you no want 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 keep both for browser storage and call your API directly, so no data pass through another person. Separating the interface from the API na pattern wey worth noticing, because na the same pattern wey let Halcyon dress Jellyfin library as 1990s rental store without changing the media server behind am.
QR codes dey come from the web client, no be the API. Shlink 5 remove the old method of adding /qr-code to short URL, so that address no longer return image. Instead, the web client draw the code for your browser without sending request to Shlink. Open the QR code option for any link inside the short URL list, set the size, format and margin, then download the result. Increase the error correction level for code wey still need scan when dem print am small or part of am dey covered. The same dialog also accept colours and logo.
Keep am running
Shortener fit fail quietly. The links stop redirecting and nobody tell you, because the person wey click am assume say the link don die. Point uptime check to real short URL instead of the home page, and alert for anything wey no be redirect. A self-hosted Uptime Kuma instance dey do this well, and e fit watch for specific status code.
Back up the database, no be the container. One command dump am.
sudo docker compose exec -T database pg_dump -U shlink shlink | gzip > shlink-$(date +%F).sql.gzThat file plus your compose file fit rebuild the whole service for new server. Every app for the box need im own version of that pair, and photo library na the awkward case, because PhotoPrism and Immich both keep the original files for disk as well as rows for database. So, dump alone go restore accounts and settings, but e no go bring back even one photo. 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 you no fit roll back migration.
FAQ
Why my short links dey return 404 after I add reverse proxy?
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.
How I fit move Shlink go another server?
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.