A privacy-first, high-performance, self-hosted URL shortener and traffic analyzer designed for teams and individuals who want full control over their link data.
13
stars
70
commits
Go
primary language
Sep 11, 2026
updated
███████╗██╗ ██╗██████╗ ██╗ ██╗ ██████╗
██╔════╝██║ ██║██╔══██╗██║ ██║██╔═══██╗
███████╗███████║██████╔╝██║ ██║██║ ██║
╚════██║██╔══██║██╔══██╗██║ ██║██║ ██║
███████║██║ ██║██║ ██║███████╗ ██ ██║╚██████╔╝
╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚╝ ╚═╝ ╚═════╝
https://shrl.io
shrl.io is a self-hosted URL shortener and traffic analyzer. It turns a Destination URL into a short, shareable URL under your own Base URL and redirects visitors there at sub-millisecond speed, while recording privacy-first traffic analytics. It is built as a small set of Go microservices — an internal API, a public auth service, a Redis-backed redirector, and an analytics worker — over PostgreSQL and Redis, with a SvelteKit admin UI, and is designed for teams and individuals who want full control over their link data.
podman compose up -d bring up the whole stack; no external accounts
required (GeoIP attribution is optional).Click a screenshot to open the full-size PNG.
0/O/1/l). Users never choose a Code. A Code
is globally unique across every Base URL. The exact Code Length defaults
to 6; an Admin can set it per instance (4–12) in Settings.base_url/code) when displaying
Links, but it is not part of a Link's identity — a Code alone identifies it.referrer, device, os, browser, country, region,
city, and the six utm_* parameters.SHRL_RETENTION_DAYS
(default 365); the lifetime visit total is never pruned.SHRL_GEOLITE_LICENSE (a free MaxMind account) to
attribute country/region/city; without it, locations report as unknown.SHRL_ADMIN_PASSWORD sets a known one). Admins create
accounts; there is no self-registration.base_url/code); Team Links have their own detail
page with analytics.http/https Destinations are accepted;
loopback, private, and link-local addresses are rejected at create/update
time.SHRL_REDIRECTOR_RATE_LIMIT_IP,
SHRL_REDIRECTOR_RATE_LIMIT_LINK; 0 disables a bucket). Excess requests
get 429 with Retry-After, are not redirected, and are not recorded as
Visits. The counters are transient TTL'd Redis keys, so visitor IPs are
still never stored (ADR 0004).(Link, day, IP + user-agent); the IP itself is never written. ┌───────────────────┐
user (browser) ───────►│ frontend (Svelte) │ login + session cookie
└─────────┬─────────┘
│ proxies with the user's token
▼
┌──────────────┐
│ api │ Internal API, frontend-only
└──────┬───────┘
│ write-through (Link cache)
script / CI ── API key ──► ┌────────────┐
│ auth │ Auth API /v1, rate-limited
└──────┬─────┘
│ write-through (Link cache)
┌──────────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │
│ source of │ │ Link cache │
│ truth │ │ Visit stream │
└──────────────┘ └──────┬───────┘
│ cache read
▼
visitor ─── GET /{code} ───► ┌──────────────┐
│ redirector │ ── 302 ──► Destination
└──────┬───────┘
│ Visit → stream
▼
┌──────────────┐ batch ┌──────────────┐
│ Redis │ ────────► │ worker │
│ Visit stream │ └──────┬───────┘
└──────────────┘ │ upsert rollups
▼
┌──────────────┐
│ PostgreSQL │
└──────────────┘
429 with
Retry-After and are neither redirected nor recorded as Visits.[!WARNING] Pre-1.0 software. Treat deployments as beta: watch releases, pin a version instead of
:latestwhen surprises are expensive, and read the release notes before upgrading. Security fixes land only in the latest release — see Security.
shrl.io publishes one image per service to ghcr.io, built for linux/amd64 and linux/arm64:
| Image | Runs | Ports |
|---|---|---|
ghcr.io/barats/shrl-io-api | Internal API (UI-only) | none, compose-network only |
ghcr.io/barats/shrl-io-auth | Auth API for API Keys | 8083 |
ghcr.io/barats/shrl-io-redirect | Redirector | 8080 |
ghcr.io/barats/shrl-io-worker | Analytics worker | none |
ghcr.io/barats/shrl-io-frontend | UI server | 8082 |
The compose.yaml in the repo root assembles the full stack: those five
images plus PostgreSQL, Redis, and the GeoIP data volume. Copy it to your
server, export the two required secrets, and start:
curl -O https://raw.githubusercontent.com/barats/shrl-io/main/compose.yaml
export SHRL_API_INTERNAL_SECRET="$(openssl rand -hex 32)"
export SHRL_SESSION_SECRET="$(openssl rand -hex 32)"
podman compose up -d
Then sign in at http://localhost:8082 with the first-run admin account:
SHRL_ADMIN_PASSWORD if you exported it, otherwise the generated value
printed once to the api service logs.
The compose file tracks the latest images, so
podman compose pull && podman compose up -d upgrades to the newest
release. To control when you upgrade, pin a release tag instead (e.g.
:0.1.0); every release also tags its minor version (:0.1) and attaches
linux/amd64 and arm64 archives of the Go services to the GitHub release.
Two first-run notes: the ghcr packages are created private — flip each
to public in its package settings so anonymous pull works — and behind an
HTTPS reverse proxy, set SHRL_COOKIE_SECURE=true and point
SHRL_DEFAULT_BASE_URL at the redirector's public URL.
shrl.io is pre-1.0. The design is privacy-first and defensive, but the software is young: watch releases, keep deployments updated, and assume the security posture will keep hardening until 1.0.
SHRL_COOKIE_SECURE=true.SHRL_API_INTERNAL_SECRET and SHRL_SESSION_SECRET with
openssl rand -hex 32; never reuse values from another deployment.SHRL_ADMIN_PASSWORD is set; change it promptly.Please do not open public issues. Use GitHub's private vulnerability
reporting (the Report a vulnerability button under the Security
tab) — see SECURITY.md for what to include and how fixes
ship. The product's security design is described under
Security model.
Prerequisites: Go 1.25+, podman (with podman-compose).
dev.compose.yaml is the local development stack (it builds images from
source). For deployment from prebuilt images, see Production above.
podman compose -f dev.compose.yaml up --build
On first run the api provisions an admin account: the password is either
SHRL_ADMIN_PASSWORD (if set) or a random value printed once to the api
service logs (podman logs shrl-io_api_1).
Services:
/v1, API-key auth)The Internal API is not published to the host (frontend-only, ADR 0015).
Scripts and CI use the public Auth API with an API key. Create a key on the Profile page of the UI (it is shown once), then:
curl -X POST http://localhost:8083/v1/links \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-d '{"base_url":"http://localhost:8080","destination":"https://example.com"}'
Then visit http://localhost:8080/{code} — you get a 302 to the destination.
The redirector rate-limits per IP (default 600 req/min) and per Link (default
3000 req/min); excess requests get 429 with a Retry-After header.
The Auth API is rate-limited per IP (default 60 req/min) and per key (300
req/min reads, 30 req/min writes); excess requests get 429 with a
Retry-After header. Link base_url must be a registered Base URL
(admin-managed); it defaults to SHRL_DEFAULT_BASE_URL (http://localhost:8080,
auto-registered on first run), or pass a base_url field to target another.
curl -s "http://localhost:8083/v1/links/{code}/analytics" \
-H "Authorization: Bearer <your-api-key>"
curl -s "http://localhost:8083/v1/links/{code}/analytics/timeseries" \
-H "Authorization: Bearer <your-api-key>"
curl -s "http://localhost:8083/v1/links/{code}/analytics/breakdowns?dimension=referrer" \
-H "Authorization: Bearer <your-api-key>"
Dimensions: referrer, device, os, browser, country, region,
city, and the six utm_* parameters (utm_source, utm_medium,
utm_campaign, utm_term, utm_content, utm_id). Bots and link-preview
unfurlers are excluded. Rollups are pruned after
SHRL_RETENTION_DAYS (default 365); the lifetime visit total is never pruned.
Country/region/city attribution is optional — set SHRL_GEOLITE_LICENSE (a
free MaxMind account) to enable it; without it, locations report as unknown.
Visitor IPs are never stored, only the derived location.
go test ./...
All services are configured via environment variables. Each service reads its own set; variables shared across services (Postgres, Redis, retention) are listed per service so every section is self-contained.
To run a service without compose (or to override compose defaults), start from
the repo-root .env.example: copy it to .env and adjust. Compose reads the
root .env automatically; standalone Go runs need it sourced first
(set -a; source .env; set +a). The file mirrors the tables below, one
section per service.
The Redis-only public server that 302s visitors to their Destination and records each Visit onto the Redis stream (ADR 0001, ADR 0018). Reads Links from Redis only, never from Postgres.
| Variable | Default | Purpose |
|---|---|---|
SHRL_REDIRECTOR_ADDR | :8080 | Redirector listen address |
SHRL_REDIRECTOR_RATE_LIMIT_IP | 600 | Per-IP redirects per minute; 0 disables |
SHRL_REDIRECTOR_RATE_LIMIT_LINK | 3000 | Per-Link redirects per minute; 0 disables |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 50 | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 5 | Minimum idle Redis connections |
The analytics aggregator: consumes the Redis visit stream in batches and upserts daily, lifetime, and breakdown rollups into Postgres in a single transaction (ADR 0003).
| Variable | Default | Purpose |
|---|---|---|
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
SHRL_GEOLITE_LICENSE | (unset) | MaxMind license key; enables GeoIP attribution |
SHRL_GEOLITE_DB_PATH | /data/GeoLite2-City.mmdb | Path to the GeoLite2 City database |
The API that serves the UI — reachable only by the frontend, which proxies every request on the signed-in user's behalf and presents the session token (ADR 0015).
| Variable | Default | Purpose |
|---|---|---|
SHRL_API_ADDR | :8080 | Internal API listen address |
SHRL_API_INTERNAL_SECRET | dev-internal-secret | Shared secret the Internal API demands on every request (set to the same value on the frontend) |
SHRL_ADMIN_USERNAME | admin | Username of the first-run Admin account |
SHRL_ADMIN_PASSWORD | (random, shown once) | First-run Admin password (bcrypt-hashed) |
SHRL_TOKEN_TTL | 86400 | Bearer token lifetime in seconds |
SHRL_CODE_LENGTH | 6 | Seed for the per-instance Code Length setting (4–12) |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL auto-registered on first run and pre-selected when creating a Link |
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
The public /v1 API for scripts and CI, authenticated by an API key on every
request and rate-limited per IP and per key (ADR 0016, ADR 0017).
| Variable | Default | Purpose |
|---|---|---|
SHRL_AUTH_ADDR | :8080 | Auth API listen address |
SHRL_AUTH_RATE_LIMIT_IP | 60 | Per-IP requests per minute |
SHRL_AUTH_RATE_LIMIT_KEY_READ | 300 | Per-key reads per minute |
SHRL_AUTH_RATE_LIMIT_KEY_WRITE | 30 | Per-key writes per minute |
SHRL_AUTH_RATE_LIMIT_FAIL | 10 | Failed key validations per minute per IP |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL pre-selected when creating a Link |
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
The SvelteKit admin UI: signs users in with an HttpOnly session cookie and proxies every API call to the Internal API (ADR 0005).
| Variable | Default | Purpose |
|---|---|---|
SHRL_API_URL | http://localhost:8080 | Internal API address the UI proxies to |
SHRL_API_INTERNAL_SECRET | dev-internal-secret | Shared secret the Internal API demands on every request (set to the same value on the api) |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL pre-selected when creating a Link |
SHRL_SESSION_SECRET | (random per boot) | HMAC secret for signing UI session cookies |
SHRL_SESSION_TTL | 86400 | UI session cookie lifetime in seconds |
SHRL_COOKIE_SECURE | false | Set true to send the session cookie over TLS only |
Programmatic access goes through the Auth API below: the public /v1
surface authenticated with an API key. The Internal API that serves the
UI is frontend-only (ADR 0015) and is not documented here.
A Link is a JSON object: base_url, code, destination, remark,
forward_utm, disabled, created_by, team_id, created_at, updated_at.
team_id is null for Personal Links. forward_utm (default false) appends
the six recognized utm_* parameters from a Visitor's short URL to the
Destination on Redirect; a same-named parameter on the Destination is
overridden, other Destination query parameters are preserved, and empty values
are skipped. forward_utm may be omitted from a PATCH to keep its current
value.
Teams are the ownership boundary for Links: a Link belongs to exactly one Team or is Personal (no Team), and a Link's Team is fixed — it never moves. Team Members see all of the Team's Links and their analytics read-only; a Link is managed by its Creator (while a member of the Team) or by a Team Owner. Membership runs on Invite Codes: a Team Owner generates single-use codes and a User joins by entering one; only an Admin adds members directly by username. Joining or leaving a Team never moves existing Personal Links.
Query parameters:
from / to (analytics reads) — YYYY-MM-DD bounds; default to the
retention window.dimension (breakdowns) — one of the analytics dimensions, default
referrer.limit (breakdowns) — top-N, default 10; 0 returns all values.The hostname query parameter was removed from every read/manage endpoint:
a Code is globally unique, so Links are identified by Code alone. base_url
is still required in the create request body (the target domain).
The Auth API is the public /v1 surface for scripts and CI. Every
request must present a valid API key as Authorization: Bearer <key>
(missing or invalid keys get 401). It serves everything except deletion for
both Personal and Team Links; admin, key-management, and login endpoints are
not exposed. Requests are rate-limited per IP and per key (see the Auth API
configuration below); excess requests get 429 with a
Retry-After header.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/links | Create a Personal Link |
| GET | /v1/links | List the caller's Personal Links (across every Base URL) |
| GET | /v1/base-urls | List registered Base URLs |
| GET | /v1/links/{code} | Get a Link (Personal or Team) |
| PATCH | /v1/links/{code} | Update a Link's Destination, Remark, Forward UTM |
| POST | /v1/links/{code}/disable | Disable a Link |
| POST | /v1/links/{code}/enable | Enable a Link |
| GET | /v1/links/{code}/analytics | Lifetime and window visit totals |
| GET | /v1/links/{code}/analytics/timeseries | Daily visit buckets in the window, ascending |
| GET | /v1/links/{code}/analytics/breakdowns | Top-N dimension values in the window |
| GET | /v1/stats | Dashboard totals and daily timeseries for the caller's Personal Links |
| GET | /v1/teams | List the caller's Teams (with their role) |
| GET | /v1/teams/{id} | Team details (members and admins) |
| GET | /v1/teams/{id}/links | The Team's Links, read-only for members |
| POST | /v1/teams/{id}/links | Create a Link in the Team (members) |
| GET | /v1/teams/{id}/stats | Dashboard totals and daily timeseries for the Team's Links (read-only for members) |
There is no delete endpoint on the Auth API (ADR 0016). Links are managed with the same permissions as in the UI: a Team Member reads Team Links; the Creator (while a member) or a Team Owner manages them. Keys are created and revoked on the Profile page of the UI.
This project uses a precise domain vocabulary (Link, Code, Base URL,
Destination, Remark, Visit, Visitor, Bot, Location, UTM Parameter, Forward UTM,
Campaign, Redirect, Disabled, Delete,
User, Admin, Creator, Personal Link, Team, Team Link, Team Owner, Team Member,
Invite Code, Token, Password). See CONTEXT.md for definitions
and the words to avoid.
Architecture decision records (ADRs) live in docs/adr/.
MIT — see LICENSE.
67 commits
3 commits
Go
57.2%
Svelte
33.1%
TypeScript
8.1%
A privacy-first, high-performance, self-hosted URL shortener and traffic analyzer designed for teams and individuals who want full control over their link data.
13
stars
70
commits
Go
primary language
Sep 11, 2026
updated
███████╗██╗ ██╗██████╗ ██╗ ██╗ ██████╗
██╔════╝██║ ██║██╔══██╗██║ ██║██╔═══██╗
███████╗███████║██████╔╝██║ ██║██║ ██║
╚════██║██╔══██║██╔══██╗██║ ██║██║ ██║
███████║██║ ██║██║ ██║███████╗ ██ ██║╚██████╔╝
╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚╝ ╚═╝ ╚═════╝
https://shrl.io
shrl.io is a self-hosted URL shortener and traffic analyzer. It turns a Destination URL into a short, shareable URL under your own Base URL and redirects visitors there at sub-millisecond speed, while recording privacy-first traffic analytics. It is built as a small set of Go microservices — an internal API, a public auth service, a Redis-backed redirector, and an analytics worker — over PostgreSQL and Redis, with a SvelteKit admin UI, and is designed for teams and individuals who want full control over their link data.
podman compose up -d bring up the whole stack; no external accounts
required (GeoIP attribution is optional).Click a screenshot to open the full-size PNG.
0/O/1/l). Users never choose a Code. A Code
is globally unique across every Base URL. The exact Code Length defaults
to 6; an Admin can set it per instance (4–12) in Settings.base_url/code) when displaying
Links, but it is not part of a Link's identity — a Code alone identifies it.referrer, device, os, browser, country, region,
city, and the six utm_* parameters.SHRL_RETENTION_DAYS
(default 365); the lifetime visit total is never pruned.SHRL_GEOLITE_LICENSE (a free MaxMind account) to
attribute country/region/city; without it, locations report as unknown.SHRL_ADMIN_PASSWORD sets a known one). Admins create
accounts; there is no self-registration.base_url/code); Team Links have their own detail
page with analytics.http/https Destinations are accepted;
loopback, private, and link-local addresses are rejected at create/update
time.SHRL_REDIRECTOR_RATE_LIMIT_IP,
SHRL_REDIRECTOR_RATE_LIMIT_LINK; 0 disables a bucket). Excess requests
get 429 with Retry-After, are not redirected, and are not recorded as
Visits. The counters are transient TTL'd Redis keys, so visitor IPs are
still never stored (ADR 0004).(Link, day, IP + user-agent); the IP itself is never written. ┌───────────────────┐
user (browser) ───────►│ frontend (Svelte) │ login + session cookie
└─────────┬─────────┘
│ proxies with the user's token
▼
┌──────────────┐
│ api │ Internal API, frontend-only
└──────┬───────┘
│ write-through (Link cache)
script / CI ── API key ──► ┌────────────┐
│ auth │ Auth API /v1, rate-limited
└──────┬─────┘
│ write-through (Link cache)
┌──────────┴──────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │
│ source of │ │ Link cache │
│ truth │ │ Visit stream │
└──────────────┘ └──────┬───────┘
│ cache read
▼
visitor ─── GET /{code} ───► ┌──────────────┐
│ redirector │ ── 302 ──► Destination
└──────┬───────┘
│ Visit → stream
▼
┌──────────────┐ batch ┌──────────────┐
│ Redis │ ────────► │ worker │
│ Visit stream │ └──────┬───────┘
└──────────────┘ │ upsert rollups
▼
┌──────────────┐
│ PostgreSQL │
└──────────────┘
429 with
Retry-After and are neither redirected nor recorded as Visits.[!WARNING] Pre-1.0 software. Treat deployments as beta: watch releases, pin a version instead of
:latestwhen surprises are expensive, and read the release notes before upgrading. Security fixes land only in the latest release — see Security.
shrl.io publishes one image per service to ghcr.io, built for linux/amd64 and linux/arm64:
| Image | Runs | Ports |
|---|---|---|
ghcr.io/barats/shrl-io-api | Internal API (UI-only) | none, compose-network only |
ghcr.io/barats/shrl-io-auth | Auth API for API Keys | 8083 |
ghcr.io/barats/shrl-io-redirect | Redirector | 8080 |
ghcr.io/barats/shrl-io-worker | Analytics worker | none |
ghcr.io/barats/shrl-io-frontend | UI server | 8082 |
The compose.yaml in the repo root assembles the full stack: those five
images plus PostgreSQL, Redis, and the GeoIP data volume. Copy it to your
server, export the two required secrets, and start:
curl -O https://raw.githubusercontent.com/barats/shrl-io/main/compose.yaml
export SHRL_API_INTERNAL_SECRET="$(openssl rand -hex 32)"
export SHRL_SESSION_SECRET="$(openssl rand -hex 32)"
podman compose up -d
Then sign in at http://localhost:8082 with the first-run admin account:
SHRL_ADMIN_PASSWORD if you exported it, otherwise the generated value
printed once to the api service logs.
The compose file tracks the latest images, so
podman compose pull && podman compose up -d upgrades to the newest
release. To control when you upgrade, pin a release tag instead (e.g.
:0.1.0); every release also tags its minor version (:0.1) and attaches
linux/amd64 and arm64 archives of the Go services to the GitHub release.
Two first-run notes: the ghcr packages are created private — flip each
to public in its package settings so anonymous pull works — and behind an
HTTPS reverse proxy, set SHRL_COOKIE_SECURE=true and point
SHRL_DEFAULT_BASE_URL at the redirector's public URL.
shrl.io is pre-1.0. The design is privacy-first and defensive, but the software is young: watch releases, keep deployments updated, and assume the security posture will keep hardening until 1.0.
SHRL_COOKIE_SECURE=true.SHRL_API_INTERNAL_SECRET and SHRL_SESSION_SECRET with
openssl rand -hex 32; never reuse values from another deployment.SHRL_ADMIN_PASSWORD is set; change it promptly.Please do not open public issues. Use GitHub's private vulnerability
reporting (the Report a vulnerability button under the Security
tab) — see SECURITY.md for what to include and how fixes
ship. The product's security design is described under
Security model.
Prerequisites: Go 1.25+, podman (with podman-compose).
dev.compose.yaml is the local development stack (it builds images from
source). For deployment from prebuilt images, see Production above.
podman compose -f dev.compose.yaml up --build
On first run the api provisions an admin account: the password is either
SHRL_ADMIN_PASSWORD (if set) or a random value printed once to the api
service logs (podman logs shrl-io_api_1).
Services:
/v1, API-key auth)The Internal API is not published to the host (frontend-only, ADR 0015).
Scripts and CI use the public Auth API with an API key. Create a key on the Profile page of the UI (it is shown once), then:
curl -X POST http://localhost:8083/v1/links \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-d '{"base_url":"http://localhost:8080","destination":"https://example.com"}'
Then visit http://localhost:8080/{code} — you get a 302 to the destination.
The redirector rate-limits per IP (default 600 req/min) and per Link (default
3000 req/min); excess requests get 429 with a Retry-After header.
The Auth API is rate-limited per IP (default 60 req/min) and per key (300
req/min reads, 30 req/min writes); excess requests get 429 with a
Retry-After header. Link base_url must be a registered Base URL
(admin-managed); it defaults to SHRL_DEFAULT_BASE_URL (http://localhost:8080,
auto-registered on first run), or pass a base_url field to target another.
curl -s "http://localhost:8083/v1/links/{code}/analytics" \
-H "Authorization: Bearer <your-api-key>"
curl -s "http://localhost:8083/v1/links/{code}/analytics/timeseries" \
-H "Authorization: Bearer <your-api-key>"
curl -s "http://localhost:8083/v1/links/{code}/analytics/breakdowns?dimension=referrer" \
-H "Authorization: Bearer <your-api-key>"
Dimensions: referrer, device, os, browser, country, region,
city, and the six utm_* parameters (utm_source, utm_medium,
utm_campaign, utm_term, utm_content, utm_id). Bots and link-preview
unfurlers are excluded. Rollups are pruned after
SHRL_RETENTION_DAYS (default 365); the lifetime visit total is never pruned.
Country/region/city attribution is optional — set SHRL_GEOLITE_LICENSE (a
free MaxMind account) to enable it; without it, locations report as unknown.
Visitor IPs are never stored, only the derived location.
go test ./...
All services are configured via environment variables. Each service reads its own set; variables shared across services (Postgres, Redis, retention) are listed per service so every section is self-contained.
To run a service without compose (or to override compose defaults), start from
the repo-root .env.example: copy it to .env and adjust. Compose reads the
root .env automatically; standalone Go runs need it sourced first
(set -a; source .env; set +a). The file mirrors the tables below, one
section per service.
The Redis-only public server that 302s visitors to their Destination and records each Visit onto the Redis stream (ADR 0001, ADR 0018). Reads Links from Redis only, never from Postgres.
| Variable | Default | Purpose |
|---|---|---|
SHRL_REDIRECTOR_ADDR | :8080 | Redirector listen address |
SHRL_REDIRECTOR_RATE_LIMIT_IP | 600 | Per-IP redirects per minute; 0 disables |
SHRL_REDIRECTOR_RATE_LIMIT_LINK | 3000 | Per-Link redirects per minute; 0 disables |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 50 | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 5 | Minimum idle Redis connections |
The analytics aggregator: consumes the Redis visit stream in batches and upserts daily, lifetime, and breakdown rollups into Postgres in a single transaction (ADR 0003).
| Variable | Default | Purpose |
|---|---|---|
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
SHRL_GEOLITE_LICENSE | (unset) | MaxMind license key; enables GeoIP attribution |
SHRL_GEOLITE_DB_PATH | /data/GeoLite2-City.mmdb | Path to the GeoLite2 City database |
The API that serves the UI — reachable only by the frontend, which proxies every request on the signed-in user's behalf and presents the session token (ADR 0015).
| Variable | Default | Purpose |
|---|---|---|
SHRL_API_ADDR | :8080 | Internal API listen address |
SHRL_API_INTERNAL_SECRET | dev-internal-secret | Shared secret the Internal API demands on every request (set to the same value on the frontend) |
SHRL_ADMIN_USERNAME | admin | Username of the first-run Admin account |
SHRL_ADMIN_PASSWORD | (random, shown once) | First-run Admin password (bcrypt-hashed) |
SHRL_TOKEN_TTL | 86400 | Bearer token lifetime in seconds |
SHRL_CODE_LENGTH | 6 | Seed for the per-instance Code Length setting (4–12) |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL auto-registered on first run and pre-selected when creating a Link |
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
The public /v1 API for scripts and CI, authenticated by an API key on every
request and rate-limited per IP and per key (ADR 0016, ADR 0017).
| Variable | Default | Purpose |
|---|---|---|
SHRL_AUTH_ADDR | :8080 | Auth API listen address |
SHRL_AUTH_RATE_LIMIT_IP | 60 | Per-IP requests per minute |
SHRL_AUTH_RATE_LIMIT_KEY_READ | 300 | Per-key reads per minute |
SHRL_AUTH_RATE_LIMIT_KEY_WRITE | 30 | Per-key writes per minute |
SHRL_AUTH_RATE_LIMIT_FAIL | 10 | Failed key validations per minute per IP |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL pre-selected when creating a Link |
SHRL_DATABASE_URL | postgres://shrl:shrl@localhost:5432/shrl | PostgreSQL connection string |
SHRL_DB_MAX_OPEN_CONNS | 20 | Max open Postgres connections |
SHRL_DB_MAX_IDLE_CONNS | 5 | Max idle Postgres connections |
SHRL_DB_CONN_MAX_LIFETIME | 30m | Max lifetime of a Postgres connection |
SHRL_DB_CONN_MAX_IDLE_TIME | 5m | Max idle time of a Postgres connection |
SHRL_REDIS_ADDR | localhost:6379 | Redis address |
SHRL_REDIS_POOL_SIZE | 0 (auto, 10×CPU) | Redis connection pool size |
SHRL_REDIS_MIN_IDLE_CONNS | 2 | Minimum idle Redis connections |
SHRL_RETENTION_DAYS | 365 | Analytics retention window (daily rollups) |
The SvelteKit admin UI: signs users in with an HttpOnly session cookie and proxies every API call to the Internal API (ADR 0005).
| Variable | Default | Purpose |
|---|---|---|
SHRL_API_URL | http://localhost:8080 | Internal API address the UI proxies to |
SHRL_API_INTERNAL_SECRET | dev-internal-secret | Shared secret the Internal API demands on every request (set to the same value on the api) |
SHRL_DEFAULT_BASE_URL | http://localhost:8080 | Base URL pre-selected when creating a Link |
SHRL_SESSION_SECRET | (random per boot) | HMAC secret for signing UI session cookies |
SHRL_SESSION_TTL | 86400 | UI session cookie lifetime in seconds |
SHRL_COOKIE_SECURE | false | Set true to send the session cookie over TLS only |
Programmatic access goes through the Auth API below: the public /v1
surface authenticated with an API key. The Internal API that serves the
UI is frontend-only (ADR 0015) and is not documented here.
A Link is a JSON object: base_url, code, destination, remark,
forward_utm, disabled, created_by, team_id, created_at, updated_at.
team_id is null for Personal Links. forward_utm (default false) appends
the six recognized utm_* parameters from a Visitor's short URL to the
Destination on Redirect; a same-named parameter on the Destination is
overridden, other Destination query parameters are preserved, and empty values
are skipped. forward_utm may be omitted from a PATCH to keep its current
value.
Teams are the ownership boundary for Links: a Link belongs to exactly one Team or is Personal (no Team), and a Link's Team is fixed — it never moves. Team Members see all of the Team's Links and their analytics read-only; a Link is managed by its Creator (while a member of the Team) or by a Team Owner. Membership runs on Invite Codes: a Team Owner generates single-use codes and a User joins by entering one; only an Admin adds members directly by username. Joining or leaving a Team never moves existing Personal Links.
Query parameters:
from / to (analytics reads) — YYYY-MM-DD bounds; default to the
retention window.dimension (breakdowns) — one of the analytics dimensions, default
referrer.limit (breakdowns) — top-N, default 10; 0 returns all values.The hostname query parameter was removed from every read/manage endpoint:
a Code is globally unique, so Links are identified by Code alone. base_url
is still required in the create request body (the target domain).
The Auth API is the public /v1 surface for scripts and CI. Every
request must present a valid API key as Authorization: Bearer <key>
(missing or invalid keys get 401). It serves everything except deletion for
both Personal and Team Links; admin, key-management, and login endpoints are
not exposed. Requests are rate-limited per IP and per key (see the Auth API
configuration below); excess requests get 429 with a
Retry-After header.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/links | Create a Personal Link |
| GET | /v1/links | List the caller's Personal Links (across every Base URL) |
| GET | /v1/base-urls | List registered Base URLs |
| GET | /v1/links/{code} | Get a Link (Personal or Team) |
| PATCH | /v1/links/{code} | Update a Link's Destination, Remark, Forward UTM |
| POST | /v1/links/{code}/disable | Disable a Link |
| POST | /v1/links/{code}/enable | Enable a Link |
| GET | /v1/links/{code}/analytics | Lifetime and window visit totals |
| GET | /v1/links/{code}/analytics/timeseries | Daily visit buckets in the window, ascending |
| GET | /v1/links/{code}/analytics/breakdowns | Top-N dimension values in the window |
| GET | /v1/stats | Dashboard totals and daily timeseries for the caller's Personal Links |
| GET | /v1/teams | List the caller's Teams (with their role) |
| GET | /v1/teams/{id} | Team details (members and admins) |
| GET | /v1/teams/{id}/links | The Team's Links, read-only for members |
| POST | /v1/teams/{id}/links | Create a Link in the Team (members) |
| GET | /v1/teams/{id}/stats | Dashboard totals and daily timeseries for the Team's Links (read-only for members) |
There is no delete endpoint on the Auth API (ADR 0016). Links are managed with the same permissions as in the UI: a Team Member reads Team Links; the Creator (while a member) or a Team Owner manages them. Keys are created and revoked on the Profile page of the UI.
This project uses a precise domain vocabulary (Link, Code, Base URL,
Destination, Remark, Visit, Visitor, Bot, Location, UTM Parameter, Forward UTM,
Campaign, Redirect, Disabled, Delete,
User, Admin, Creator, Personal Link, Team, Team Link, Team Owner, Team Member,
Invite Code, Token, Password). See CONTEXT.md for definitions
and the words to avoid.
Architecture decision records (ADRs) live in docs/adr/.
MIT — see LICENSE.
67 commits
3 commits
Go
57.2%
Svelte
33.1%
TypeScript
8.1%