A minimal, self-hosted URL shortener. Paste a URL, pick an optional alias, set an optional expiry, share something shorter.
Built with Go, React, and PostgreSQL. No accounts, no analytics, no clutter.
Quick Start Β Β·Β Features Β Β·Β Screenshots Β Β·Β API Β Β·Β Configuration Β Β·Β Development
- π Short links in one click β paste a URL, get a short link.
- βοΈ Custom aliases β claim memorable names like
trimora.app/launch-notes. - β³ Optional expiry β links can expire after
1h,1d,7d, or30d. Default is forever. - β»οΈ Smart de-duplication β the same URL with no alias and no expiry returns the same short code.
- πͺΆ Calm, accessible UI β single page, mobile-first, warm editorial style, no tracking.
- π©Ί Production-ready health checks β
/livezand/readyzfor Kubernetes / load balancers. - π³ One-command deploy β
docker compose up, with optional bundled Postgres or your own (Supabase, Neon, RDS). - π§ͺ Tested β Go unit tests, ESLint, strict TypeScript, Playwright UI checks.
| Desktop Β· Empty form | Desktop Β· Result with expiry | Mobile |
![]() |
![]() |
![]() |
Prerequisites: Docker or Go 1.25+ with Node.js 20+ and a PostgreSQL 14+ database.
π³ Docker Compose (recommended)
Spin up the full stack β API, web UI, and a local Postgres β with one command:
git clone https://github.com/your-org/trimora.git
cd trimora
cp .env.example .env
docker compose --profile local-db up --buildThen open http://localhost:5173.
Want to point at an external database (Supabase, Neon, RDS)? Drop the
--profile local-dbflag and setDATABASE_URLin.env.
βοΈ Docker + Supabase (or any managed Postgres)
cp .env.example .env
# Edit .env and set DATABASE_URL to your managed Postgres connection string.
# For Supabase, use the direct (non-pooling) connection on port 5432
# with sslmode=require.
docker compose up --buildTrimora opens persistent connections and runs CREATE TABLE IF NOT EXISTS on
boot, which works best on the direct port (5432) rather than the
pgbouncer pooler (6543).
π οΈ Local development (no Docker)
1. Start Postgres (any local Postgres works):
docker run -d --rm --name trimora-pg \
-e POSTGRES_USER=trimora -e POSTGRES_PASSWORD=trimora -e POSTGRES_DB=trimora \
-p 5432:5432 postgres:16-alpine2. Start the Go API:
cd api
DATABASE_URL='postgres://trimora:trimora@localhost:5432/trimora?sslmode=disable' \
BASE_URL='http://localhost:8080' \
ALLOWED_ORIGINS='http://localhost:5173' \
PORT=8080 \
go run ./cmd/server3. Start the Vite web app:
cd web
npm install
VITE_API_BASE_URL='http://localhost:8080' npm run devOpen http://localhost:5173.
Base URL: http://localhost:8080 (defaults; override with BASE_URL).
POST /api/shorten β create a short link
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | http:// or https://, max 2048 chars. |
alias |
string | no | 3β32 chars, [A-Za-z0-9_-]. Reserved: api, livez, readyz, healthz, health, static, admin, assets, favicon.ico, robots.txt. |
expires_in |
"1h" | "1d" | "7d" | "30d" |
no | Omit for permanent links. |
Example
curl -X POST http://localhost:8080/api/shorten \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/long/article",
"alias": "launch-notes",
"expires_in": "1d"
}'Response β 201 Created
{
"code": "launch-notes",
"short_url": "http://localhost:8080/launch-notes",
"url": "https://example.com/long/article",
"expires_at": "2026-04-28T15:33:09+05:30"
}Errors
| Status | When |
|---|---|
400 Bad Request |
invalid URL, invalid/reserved alias, invalid expires_in |
409 Conflict |
alias already taken |
GET /{code} β follow a short link
curl -i http://localhost:8080/launch-notes
# HTTP/1.1 302 Found
# Location: https://example.com/long/article| Status | When |
|---|---|
302 Found |
redirect to the original URL |
404 Not Found |
unknown code (HTML page for browsers, JSON for API clients) |
410 Gone |
link expired (HTML page for browsers, JSON for API clients) |
GET /livez, /readyz, /healthz β health checks
| Endpoint | Purpose |
|---|---|
/livez |
Process is alive. No DB call. Use as Kubernetes liveness probe. |
/readyz |
Database is reachable (PING with 2s timeout). Use as readiness probe. |
/healthz |
Backwards-compatible alias for /livez. |
curl http://localhost:8080/livez
# {"status":"ok"}Trimora is configured entirely through environment variables. Copy .env.example to .env to get started.
Server (API)
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
API listen port. |
BASE_URL |
http://localhost:8080 |
Public origin used to build short_url in responses. |
ALLOWED_ORIGINS |
http://localhost:5173 |
Comma-separated CORS allow-list. |
DATABASE_URL |
required | PostgreSQL connection string. |
Database
# Local Postgres via docker compose --profile local-db
DATABASE_URL=postgres://trimora:trimora@db:5432/trimora?sslmode=disable
# Managed Postgres (Supabase, Neon, RDS, β¦) β sslmode=require
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/DB?sslmode=requireThe schema is auto-created on boot (links table + indexes). No migration tool required.
Web (Vite)
| Variable | Default | Description |
|---|---|---|
VITE_API_BASE_URL |
empty | Absolute API URL for production builds. Leave empty in dev to use the proxy. |
VITE_API_PROXY_TARGET |
http://localhost:8080 |
Vite dev-server proxy target for /api, /livez, /readyz. |
Trimora/
βββ api/ # Go backend
β βββ cmd/server/ # main entrypoint
β βββ internal/
β βββ config/ # env loading
β βββ httpapi/ # handlers, router, CORS, branded 404/410
β βββ links/ # service + repository (database/sql + pgx)
β βββ shortcode/ # base62 short-code generator (crypto/rand)
β βββ storage/ # connection + schema bootstrap
β βββ validate/ # URL, alias, expiry rules
βββ web/ # Vite + React + TypeScript frontend
β βββ src/
β βββ components/ # ShortenForm, Result
β βββ types/api.ts # shared API types
β βββ api.ts # tiny fetch client
β βββ App.tsx
βββ docs/screenshots/ # README assets
βββ docker-compose.yml
βββ README.md
βββ LICENSEBackend checks
cd api
gofmt -l .
go vet ./...
go test ./...
go build ./...Frontend checks
cd web
npm run typecheck
npm run lint
npm run buildEnd-to-end UI testing (Playwright)
The web UI is verified manually with Playwright on both desktop (1280Γ820) and mobile (390Γ844) viewports. The screenshots in this README are captured headlessly via the same flow.
- No accounts, no analytics, no dashboard. Trimora is a tool, not a product.
database/sql+ pgx β no ORM, no migration framework. The schema is small enough to manage in code.crypto/rand-backed base62 codes β short, URL-safe, collision-retried at the service layer.- Optional expiry is a single nullable
expires_atcolumn with an index. Expired links return410 Gone. - Same URL = same short code when no alias and no expiry are requested, to avoid pointless duplicates.
- Branded 404 / 410 pages for browsers, JSON for API clients β content-negotiated by
Accept.
Issues and pull requests are welcome. Please keep changes small and focused, and run the checks listed above before opening a PR.
MIT Β© Trimora contributors

