Skip to Content

Production Guide: Deploy Shlink with Docker Compose + Caddy + PostgreSQL on Ubuntu

Self-host a branded URL shortener with automatic HTTPS, REST API, and click analytics

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 version should 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 .env

Edit .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 shlink

Watch 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_DOMAIN to 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_KEY in 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.tool

Expected: "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.tool

You should receive a JSON response containing the shortUrl field with your new short link.

Verify the redirect works

curl -I https://s.example.com/SHORTCODE

Expected: 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.tool

Common issues and fixes

  • Shlink exits during startup with DB connection error — PostgreSQL is not yet ready. The depends_on healthcheck should prevent this, but if it persists, check docker compose logs postgres for migration or permission errors. Confirm the credentials in .env match exactly what is set for the postgres service.
  • Caddy returns 502 Bad Gateway — The Shlink container is still starting or has crashed. Run docker compose ps to check the status. Shlink can take 30–60 seconds for first-run migrations. If the container is in a restart loop, check docker compose logs shlink for PHP or DB errors.
  • TLS certificate not issued — Verify DNS has propagated (dig s.example.com must return your server IP) and ports 80/443 are reachable. Check docker compose logs caddy for 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_DOMAIN in .env matches the domain in the Caddyfile exactly (no trailing slash). Shlink uses the domain to construct redirect responses.
  • API returns 403 Forbidden — The X-Api-Key header value does not match the key stored in the database. If you changed INITIAL_API_KEY after 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

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.

Contact Us

Production Guide: Deploy Nextcloud with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host Nextcloud securely with automatic HTTPS, PostgreSQL backend, and a hardened Docker Compose configuration