Self-hosted URL shortening platform with a built-in dashboard, multi-domain workspaces, team permissions, analytics, and a REST API.
- Short URLs with optional custom shortcode, notes, tags, teams, expiration, hit limits, and UTM templates
- Multi-domain in a single deployment (each domain maps to an organization/workspace)
- Authentication & access control via Better-Auth organizations + roles + API keys
- Teams inside an organization, with per-team policy enforcement (create/read/update/delete)
- Metrics collected on redirect (browser/device/os/language/referrer/geo/UTMs + internal vs external)
- Integrations
- Umami (server-side event tracking)
- VirusTotal API (domain validation / blacklist enforcement)
- API Reference UI with Scalar, serving both Slugs + Better-Auth schemas
- Theme override via a single
custom.cssfile (CSS variables → Tailwind tokens)
Slugs is multi-domain without running multiple instances.
- You configure a list of
hostsinsettings.yaml - Each
host.originmaps to an organization id:slugify(origin) - At request time, Slugs resolves the current host from
event.url.origin - The resolved host selects:
- the active organization
- the Better-Auth instance (cached per host)
- per-host options (signup, 2FA, homepage, limits, lowercase fallback, integrations, etc.)
On authenticated requests, if there is no activeOrganizationId, Slugs sets it to the organization derived from the current host.
If the user is not a member of that organization, access is denied (invitation flow is checked).
- Node.js 22+ for production-like runs
- Bun for install/build/dev scripts used by this project
- Postgres database
- Docker or Docker Compose (recommended for deployment/local database)
Start from the example file:
cp .env.example .envRequired values:
DATABASE_URL=postgres://slugs:strong-password@localhost:5432/slugs
BETTER_AUTH_SECRET=change-this-to-a-long-random-value
SLUGS_ORIGIN=http://localhost:5173SLUGS_ORIGIN is used for auth, emails, organization bootstrap, and generated links. In hosted environments it can reference platform variables, for example https://$(PRIMARY_DOMAIN).
For Easypanel deploys, use runtime environment variables instead of editing domains or database URLs into the image.
Recommended minimum:
DATABASE_URL=postgres://usuario:senha@host:5432/database
SLUGS_ORIGIN=https://$(PRIMARY_DOMAIN)
SLUGS_APPNAME=$(PROJECT_NAME)
BETTER_AUTH_SECRET=troque-por-um-valor-longo-e-fixoSee docs/easypanel-deploy.md for the full deployment checklist, healthcheck notes, and crash-loop prevention behavior.
services:
slugs:
image: uraniadev/slugs:latest
ports:
- '1000:1000'
environment:
DATABASE_URL: 'postgres://slugs:slugs_password@db:5432/slugs'
BETTER_AUTH_SECRET: 'change-me'
SLUGS_ORIGIN: 'http://localhost:1000'
volumes:
- ./config:/app/config
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: slugs
POSTGRES_PASSWORD: slugs_password
POSTGRES_DB: slugs
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:The production image listens on port 1000 by default. If you deploy behind a reverse proxy, route external traffic to the container's 1000 port and set SLUGS_ORIGIN to the public HTTPS origin.
Slugs reads configuration from config/settings.yaml.
Minimal example:
appname: Slugs
admin:
- email: admin@example.org
username: admin
hosts:
- origin: 'https://slugs.li'
options:
customRedirect: '/dashboard'
smtp:
enabled: false
# this will log outbound emailsRuntime environment variables can override the first host and bootstrap admin without rebuilding the image. The most common overrides are:
SLUGS_APPNAMESLUGS_ORIGINSLUGS_ADMIN_EMAILSLUGS_ADMIN_USERNAMESLUGS_BOOTSTRAP_ADMIN_TEMP_PASSWORDSLUGS_BOOTSTRAP_ADMIN_RESETSLUGS_DISABLE_SIGNUPSLUGS_DISABLE_2FASLUGS_DISABLE_HOMEPAGESLUGS_DISABLE_LIMITSSLUGS_DISABLE_LOWER_CASE_FALLBACKSLUGS_CUSTOM_REDIRECTSLUGS_LIMIT_MAX_SLUGS_PER_USERSLUGS_LIMIT_REQUESTS_PER_DAYSLUGS_LIMIT_REQUESTS_PER_MINUTESLUGS_SMTP_*SLUGS_MAXMIND_DB_PATHMAXMIND_LICENSE_KEY
See .env.example and docs/easypanel-deploy.md for a deploy-oriented list.
On startup, Slugs ensures:
adminusers exist (created if missing)- an organization exists per configured host (
id = slugify(origin)) - admin users are members of every organization (as
owner) - organization roles (owner/admin/member) are materialized with stored permissions
Teams exist inside an organization and are used to scope sharing/visibility and enforcement.
- Roles:
owner,admin,member - Teams: organization-defined groups (e.g. “Marketing”, “Support”)
Permissions are stored per organization role and keyed by team id:
createreadupdatedelete
Owners are not restricted by team policy (UI disables changing owner permissions).
When you toggle permissions in the UI, Slugs persists the permission graph and invalidates cached auth configuration so changes take effect immediately.
Slugs collects metrics during redirect resolution.
On a successful redirect (and when not blocked by secret checks), Slugs may store:
- timestamp
- browser / os / device / cpu
- language
- referrer
- geo (city / region / country) from IP (IPs are not saved)
- UTM payload attached to the URL
- internal vs external visit classification
- internal/external is computed by comparing the metric’s organization id to the URL’s owning organization id
The metrics dashboard supports:
- date range selection
- preset ranges (week / 2 weeks / month / 6 months / year)
- organization selection (admin-only cross-org view)
- UTM key filtering + per-key value filtering
- breakdowns (browsers, devices, OS, languages, referrers, countries, regions, cities, visitor organizations)
Country, region and city breakdowns require a MaxMind GeoLite2 City .mmdb file. Set MAXMIND_LICENSE_KEY to download it automatically on startup, set SLUGS_MAXMIND_DB_PATH, or place the file at config/maxmind/geolite2-city.mmdb. Without it, visits are still tracked and grouped as Other.
Integrations are configured per host (per domain).
If enabled, Slugs sends server-side events to Umami:
- redirects
- “not found” shortcodes
- invalid login attempts
- DB unavailable alerts
- secret invalid attempts
Configured in Settings → Integrations or in settings.yaml:
thirdparty:
umami:
url: 'https://umami.example.org'
websiteId: 'your-website-id'VTAPI is used to validate redirect targets and enforce blacklist rules (watchlist).
Configured in Settings → Integrations or in settings.yaml:
thirdparty:
vtapi:
apikey: 'your-virustotal-api-key'GET /api- readiness/health details, including database statusGET /api/live- liveness check that does not depend on the databaseGET /api/docs- Scalar API reference UIGET /api/openapi.json- Slugs OpenAPI schemaGET /api/urls/POST /api/urls- list and create short URLsGET /api/urls/:id/PATCH /api/urls/:id/DELETE /api/urls/:id- inspect, update, and delete a URLGET /api/urls/:id/stat- URL statisticsPUT|PATCH|DELETE /api/urls/:id/tag- manage URL tagsPUT|PATCH|DELETE /api/urls/:id/teams- manage URL team visibilityGET /api/tags/POST /api/tags- list and create tagsGET /api/tags/:id/DELETE /api/tags/:id- inspect and delete a tagGET /api/metrics- metrics dashboard dataPOST /api/:shortcode- API redirect lookup for a shortcode
The API reference is served with Scalar at:
Scalar exposes two schemas from the same UI:
- Slugs OpenAPI (
/api/openapi.json) - Better-Auth OpenAPI (
/api/auth/open-api/generate-schema/)
The REST API uses Authorization: Bearer <api-key> and verifies permissions scoped to the current host organization.
Slugs supports runtime theme overrides via a single file:
config/custom.css(mounted into the container)
It is served at:
/custom.css(cached for 1 year)
The file is included at the end of the HTML document:
<link rel="stylesheet" href="/custom.css" />Recommended workflow:
- Generate a theme (Shadcn theme generator, etc.)
- Copy only
:root { ... }and.dark { ... } - Paste into
config/custom.css
This overrides the Tailwind-consumed CSS variables without changing component code or rebuilding the app.
Typical stack:
- SvelteKit (SSR)
- Postgres
- Drizzle ORM
- Better-Auth
- Scalar API Reference UI
- Bun for dependency install and development commands
- Node.js for the production runner (
scripts/start.mjs)
Run a local database:
bun run db:startThe checked-in compose.yaml starts a Postgres instance on localhost:5432 with:
DATABASE_URL=postgres://root:mysecretpassword@localhost:5432/localThen install dependencies and run the app:
bun install
cp .env.example .env
# edit DATABASE_URL, BETTER_AUTH_SECRET, and SLUGS_ORIGIN if needed
bun run devUseful commands:
bun run check
bun run lint
bun run build
bun run db:push
bun run db:generate
bun run db:migrate
bun run db:studioFor a production-like local run:
bun run build
npm install --omit=dev --legacy-peer-deps
node ./scripts/start.mjsSee LICENSE.
PRs welcome. Focus areas that matter most:
- correctness of permission enforcement
- redirect path robustness (secrets, blacklist, expiration, limits)
- metrics aggregation performance
- multi-domain safety (host resolution, org scoping)
- I18N Support validation
Some validation and code optimizations were assisted by AI, but Slugs remains an artisanal project. Most AI usage focused on generating documentation content and I18N translations.