Every team that runs its own infrastructure eventually needs a self-hosted URL shortener — whether for tracking campaign links, shortening internal service URLs, or building branded short domains without handing data to a third-party provider. Shlink is a production-grade, open-source URL shortener written in PHP that supports custom short domains, click analytics, REST API access, and QR code generation out of the box. This guide walks you through a complete production deployment of Shlink using Docker Compose, Caddy as the reverse proxy with automatic TLS, and PostgreSQL as the primary database, all on Ubuntu 22.04 or 24.04.
Architecture and flow overview
The stack consists of three services managed by Docker Compose:
- Shlink (app container) — the PHP application server, exposing the URL shortener API and redirect engine on an internal port
- PostgreSQL — the relational database storing all short URLs, click events, tags, domains, and API keys
- Caddy — the reverse proxy handling inbound HTTPS, automatic certificate provisioning via ACME/Let's Encrypt, and forwarding requests to the Shlink container
All three services communicate over a private Docker network (shlink_net). PostgreSQL data is persisted on a named volume. Caddy terminates TLS on ports 80 and 443 and proxies HTTP to the Shlink service. No database port is exposed to the host. Shlink itself is stateless beyond the database, so horizontal scaling or a rolling restart requires no additional coordination.
Prerequisites
- Ubuntu 22.04 or 24.04 server with a public IP
- A DNS A record for your short domain (e.g.,
s.example.com) pointing to the server IP - Docker Engine and Docker Compose v2 installed (
docker compose versionshould print v2.x) - Ports 80 and 443 open in your firewall (
ufw allow 80/tcp && ufw allow 443/tcp) - A strong, randomly generated API key for Shlink access (use
openssl rand -hex 20)
Step-by-step deployment
1. Create the project directory and environment file
mkdir -p /opt/shlink && cd /opt/shlink
touch .envEdit .env with your values. Never commit this file to version control:
POSTGRES_DB=shlink
POSTGRES_USER=shlink_user
POSTGRES_PASSWORD=replace_with_strong_password
SHLINK_DEFAULT_DOMAIN=s.example.com
SHLINK_IS_HTTPS_ENABLED=true
SHLINK_INITIAL_API_KEY=replace_with_openssl_rand_hex_20
[email protected]2. Write the Docker Compose file
services:
shlink:
image: shlinkio/shlink:stable
container_name: shlink
restart: unless-stopped
environment:
DEFAULT_DOMAIN: ${SHLINK_DEFAULT_DOMAIN}
IS_HTTPS_ENABLED: ${SHLINK_IS_HTTPS_ENABLED}
DB_DRIVER: postgres
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
INITIAL_API_KEY: ${SHLINK_INITIAL_API_KEY}
depends_on:
postgres:
condition: service_healthy
networks:
- shlink_net
postgres:
image: postgres:16-alpine
container_name: shlink_postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- shlink_net
caddy:
image: caddy:alpine
container_name: shlink_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- shlink_net
networks:
shlink_net:
volumes:
postgres_data:
caddy_data:
caddy_config:3. Write the Caddyfile
s.example.com {
tls [email protected]
reverse_proxy shlink:8080
encode gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy no-referrer
}
}Replace s.example.com and [email protected] with your actual domain and email.
4. Start the stack
cd /opt/shlink
docker compose up -d
docker compose logs -f shlinkWatch for the log line Application is up and running from the Shlink container. Caddy will automatically obtain a TLS certificate on first request. First startup may take 30–60 seconds while Shlink runs database migrations.
Configuration and secrets handling
All sensitive values live in /opt/shlink/.env. Key configuration guidance:
- SHLINK_INITIAL_API_KEY — this key is injected into the database on first run. Store it in a password manager immediately; there is no UI to retrieve it later. To rotate, use the Shlink CLI:
docker compose exec shlink shlink api-key:generate. - DB_PASSWORD — use a randomly generated string of at least 32 characters. Shlink connects over the Docker internal network, but the password still belongs in a secrets store if this is shared infrastructure.
- Multiple short domains — Shlink supports multiple domains. To add a second domain, set
DEFAULT_DOMAINto the primary and add additional Caddy blocks, then register the domain via the API:POST /rest/v3/domains. - Geolite2 MaxMind — Shlink can resolve click locations using GeoLite2 data. Set
GEOLITE_LICENSE_KEYin the environment file to enable this feature after registering a free MaxMind account.
Verification
Run these checks after the stack starts:
Check Shlink API health
curl -s https://s.example.com/rest/v3/health | python3 -m json.toolExpected: "status": "pass"
Create your first short URL
curl -s -X POST https://s.example.com/rest/v3/short-urls \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"longUrl": "https://example.com/some/long/path"}' \
| python3 -m json.toolYou should receive a JSON response containing the shortUrl field with your new short link.
Verify the redirect works
curl -I https://s.example.com/SHORTCODEExpected: HTTP/2 302 redirect to the original URL.
Check click statistics
curl -s https://s.example.com/rest/v3/short-urls/SHORTCODE/visits \
-H "X-Api-Key: YOUR_API_KEY" | python3 -m json.toolCommon issues and fixes
- Shlink exits during startup with DB connection error — PostgreSQL is not yet ready. The
depends_onhealthcheck should prevent this, but if it persists, checkdocker compose logs postgresfor migration or permission errors. Confirm the credentials in.envmatch exactly what is set for thepostgresservice. - Caddy returns 502 Bad Gateway — The Shlink container is still starting or has crashed. Run
docker compose psto check the status. Shlink can take 30–60 seconds for first-run migrations. If the container is in a restart loop, checkdocker compose logs shlinkfor PHP or DB errors. - TLS certificate not issued — Verify DNS has propagated (
dig s.example.commust return your server IP) and ports 80/443 are reachable. Checkdocker compose logs caddyfor ACME challenge errors. Ensure the email in the Caddyfile is valid for Let's Encrypt ToS acceptance. - Short URL redirects loop or 404 — Confirm
DEFAULT_DOMAINin.envmatches the domain in the Caddyfile exactly (no trailing slash). Shlink uses the domain to construct redirect responses. - API returns 403 Forbidden — The
X-Api-Keyheader value does not match the key stored in the database. If you changedINITIAL_API_KEYafter first run, it will not take effect. Generate a new key via:docker compose exec shlink shlink api-key:generate.
FAQ
Does Shlink support custom slugs for short URLs?
Yes. Pass the customSlug field in the POST body when creating a short URL: "customSlug": "my-campaign". If the slug is already taken, Shlink returns a 409 conflict. Slugs are case-insensitive by default.
Can I run Shlink Web Client (the admin UI) alongside this stack?
Yes. Shlink Web Client is a standalone React SPA that connects to the Shlink REST API. Add it as a fourth Docker Compose service using the shlinkio/shlink-web-client image, expose it on a separate subdomain (e.g., shlink-admin.example.com) via a second Caddy block, and point it to https://s.example.com as the API URL. No backend changes are needed.
How do I back up the Shlink database?
Run a standard PostgreSQL dump from the running container: docker compose exec postgres pg_dump -U shlink_user shlink | gzip > /opt/shlink/backups/shlink_$(date +%F).sql.gz. Add this to a cron job and ship the output to offsite storage (S3, Backblaze B2) using the Kopia or Rclone guides.
Does Shlink track visitor IP addresses by default?
Shlink logs click events including approximate geolocation (when GeoLite2 is configured) but does not store raw visitor IPs by default. You can control the level of analytics detail via the VISITS_WEBHOOKS_* environment variables and the disable_track_param query parameter on short URLs.
Can I use Shlink as a QR code generator?
Yes. Append /qr-code to any short URL endpoint to get a QR code PNG: GET /rest/v3/short-urls/SHORTCODE/qr-code?size=300. The QR code encodes the short URL itself so scanning it triggers the same redirect tracking.
How do I add a second short domain to the same Shlink instance?
Register the domain via the API: POST /rest/v3/domains with body {"authority": "go.example.com"}. Add a corresponding Caddy block for the new domain pointing to the same Shlink container. Shlink automatically differentiates slugs per domain, so s.example.com/abc and go.example.com/abc are treated as distinct short URLs.
Internal links
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy on Ubuntu
- Production Guide: Deploy Authentik with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
- Production Backups with Kopia, S3 Storage, Docker Compose, and Caddy
Talk to us
If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.