A self-hosted URL shortener built with SvelteKit, Prisma, PostgreSQL, and Redis. Auth-gated link creation, public redirects, per-link analytics, brandable, and API-key accessible.
expiresAt, activatesAt (scheduled activation), and maxClicks per link/settings, override per linkcode → URL lookups/settingsAuthorization: Bearer on every /api/* endpointdocker compose up locally, bundled Caddy override for one-command TLS, or one-click deploy to Railway| Layer | Tech |
|---|---|
| Frontend | SvelteKit + Tailwind CSS |
| Backend | SvelteKit server routes (Node) |
| ORM | Prisma |
| DB | PostgreSQL 16 |
| Cache | Redis 7 |
| Auth | JWT (HTTP-only cookie) + bcrypt, plus API key (sha256) |
git clone https://github.com/uAliAmer/Svelte-URL-Shortener.git
cd Svelte-URL-Shortener
cp .env.example .env
# Edit .env — set a strong JWT_SECRET (e.g. openssl rand -hex 32)
# Optionally change POSTGRES_PASSWORD and PUBLIC_BASE_URL
docker compose up -d --build
docker compose exec app npm run db:seed # creates a demo admin
Open http://localhost:3000 and log in:
[email protected]demo1234You'll be redirected to /account/setup to choose new credentials before continuing. The first registered user is automatically promoted to admin (so the seed is optional — you can also sign up fresh).
Migrations run automatically on container start (prisma migrate deploy).
All env vars live in .env (see .env.example):
| Variable | Default | Description |
|---|---|---|
PUBLIC_BASE_URL |
http://localhost:3000 |
The public URL where this app is reachable. Used to build short-link strings shown in the dashboard. |
APP_PORT |
3000 |
Host port mapped to the container. |
JWT_SECRET |
(required) | Secret for signing session JWTs. |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
snip / snip / snip |
Postgres credentials. |
Instance-level settings (brand title, tagline, signup toggle, default link expiry, API-key enable, default QR style) are configured at runtime from /settings (admins only) — no env vars or restarts needed.
Railway provides managed Postgres + Redis, builds from your Docker image, and gives you a public URL — no extra config files needed.
Create a new project in Railway and connect this repo (or your fork).
Add two services from the Railway dashboard: Postgres and Redis (both are one-click).
Configure the app service — Railway auto-detects the Dockerfile. Set these variables:
| Variable | Value |
|---|---|
DATABASE_URL |
${{Postgres.DATABASE_URL}} (reference the Postgres service) |
REDIS_URL |
${{Redis.REDIS_URL}} (reference the Redis service) |
JWT_SECRET |
a 32-byte random string (openssl rand -hex 32) |
PUBLIC_BASE_URL |
your Railway-generated URL (or custom domain) |
ORIGIN |
same as PUBLIC_BASE_URL |
NODE_ENV |
production |
Generate a public domain for the app service (Settings → Networking → Generate Domain), then paste it back into PUBLIC_BASE_URL and ORIGIN and redeploy.
Migrations apply automatically on container start. Optionally seed a demo admin by running npm run db:seed once from the Railway shell — or just sign up at /register (the first user is auto-promoted to admin).
That's it.
Want your instance at links.acme.com instead of localhost:3000? Pick the path that matches your host:
Open your app service → Settings → Networking → Custom Domain and enter your hostname. Railway provisions a TLS cert for you. Then update PUBLIC_BASE_URL and ORIGIN to https://your-domain and redeploy.
The repo ships a Caddy override that handles TLS + Let's Encrypt automatically. In .env:
DOMAIN=links.acme.com
[email protected]
PUBLIC_BASE_URL=https://links.acme.com
Point your domain's A/AAAA record at the server, then:
docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build
Caddy binds 80/443, terminates TLS, and reverse-proxies to the app (port 3000 is no longer exposed to the host). Certs renew automatically and persist in the snip-caddy-data volume. To go back to plain HTTP, omit the -f docker-compose.caddy.yml flag on subsequent docker compose commands.
If you already run Nginx, Traefik, or another front-end, just keep the default docker-compose.yml (app on port 3000) and proxy to it. Make sure PUBLIC_BASE_URL and ORIGIN match the public HTTPS URL — SvelteKit's CSRF check rejects form submissions when Origin doesn't match.
npm install
cp .env.example .env
export DATABASE_URL="postgresql://snip:snip@localhost:5432/snip"
export REDIS_URL="redis://localhost:6379"
export JWT_SECRET="dev-secret"
npx prisma migrate dev
npm run db:seed
npm run dev
The web UI uses cookie sessions. For programmatic use, create an API key at /settings and send it as Authorization: Bearer <key>. Two scopes:
snip_f_… — full access (read + write)snip_r_… — read-only (rejects writes with 403)Full reference with curl examples lives at /docs/api once the app is running.
| Method | Path | Description |
|---|---|---|
POST |
/api/auth/register |
{ email, password } (gated by admin signup toggle) |
POST |
/api/auth/login |
{ email, password } → sets session cookie |
POST |
/api/auth/logout |
clears session cookie |
GET |
/api/links |
List your links |
POST |
/api/links |
{ originalUrl, customSlug?, expiresAt?, activatesAt?, maxClicks?, tag? } |
GET |
/api/links/:id |
Get link + recent clicks |
PATCH |
/api/links/:id |
{ isActive?, originalUrl?, expiresAt?, activatesAt?, maxClicks?, tag?, qrStyle? } |
DELETE |
/api/links/:id |
Delete a link |
GET |
/api/keys |
List your API keys (no plaintext) |
POST |
/api/keys |
{ name, scope } → returns plaintext once |
DELETE |
/api/keys/:id |
Revoke a key |
GET |
/:code |
Public redirect |
Admin-only endpoints (/api/admin/*) accept cookie sessions only — never API keys.
User → id, email, passwordHash, isAdmin, canUseApi, mustChangeCredentials, createdAt
Link → id, code, originalUrl, userId, clickCount, expiresAt, activatesAt, maxClicks, isActive, tag, qrStyle (JSON), createdAt
Click → id, linkId, ip, referrer, userAgent, country, createdAt
ApiKey → id, userId, name, prefix, hash, scope, lastUsedAt, revokedAt, createdAt
Setting → key, value (JSON-encoded, single global namespace)
Available at /settings — admins see the full instance-management UI; non-admin users see only their own API keys section. From there you can:
/api/auth/register returns 403)The last admin cannot be deleted or demoted. You can't delete yourself.
On any link's detail page (/links/<id>), the Customize QR button reveals the same style editor scoped to that one link. Toggle Use instance default style to clear an override. Downloads always render at 1024×1024 and reflect the active style (shapes, logo, colors).
PUBLIC_BASE_URL to your production URL so the dashboard renders correct short-link strings.ORIGIN is also passed through to the Node adapter to satisfy SvelteKit's CSRF check.JWT_SECRET (32+ random bytes) and don't commit .env..github/workflows/ci.yml runs on every PR and push to main:
npm ci + prisma generate + vite buildGreen build = the app compiles and Prisma schema is valid.
MIT — see LICENSE.