A self-hosted platform for short links.
## Features
- **URL Shortener**: branded short links on your own domains.
- **Dynamic Links**: rules that send each visitor to the right destination.
- **Custom domains with automatic HTTPS** through optional Caddy sync.
- **REST API** with scoped API keys (spec in `resources/openapi/`).
- **Link Tree** *(coming soon)*: one hosted page for all your links.
## Getting started
### Prerequisites
- [Bun](https://bun.sh) 1.4+
- [PostgreSQL](https://www.postgresql.org) 15+
linq never runs Postgres itself. Without it, the server exits with
`ERR_POSTGRES_CONNECTION_REFUSED`. Redis is optional, because the redirect cache
runs in the process by default.
### Installation
```bash
git clone https://github.com/org-quicko/linq.git
cd linq
bun install
psql -U postgres -c "CREATE DATABASE linq;"
cp .env.example .env # then set DATABASE_URL to your Postgres credentials
```
linq applies its own migrations at boot, but it does not create the database.
### Usage
Start the API server and the Client UI in two terminals:
```bash
bun run dev # API and redirects on :3000
bun run dev:client # Client UI on :3001
```
The first time the server boots with **no API keys**, it prints an admin key
once:
```
linq admin API key: linq_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Copy it now. Only its hash is stored. To mint another key without restarting,
run `bun run key:create --name ops --preset admin` (add `--expires ` for
an expiry). If every key is ever revoked, the next restart mints a new admin key.
Open **http://localhost:3001/home/** and add a server:
| Field | Value |
| ---------- | ----------------------------- |
| Name | anything, e.g. `local` |
| Server URL | `http://localhost:3000` |
| API key | the `linq_…` key from above |
The UI is a standalone client. It keeps a list of servers in the browser and
calls whichever one you pick, across origins and without a proxy. If requests
fail with an opaque network error, check the server's CORS setup first. See
`resources/docs/adr/0006`.
| URL | What it is |
| ------------------------------ | ---------------------------------------- |
| http://localhost:3001/home/ | Client UI (development) |
| http://localhost:3000/home/ | Client UI (served from the built export) |
| http://localhost:3000/api/v1 | REST API |
| http://localhost:3000/`slug` | A short link |
The root of a domain (`http://localhost:3000/`) is not the UI. It redirects to
that domain's fallback URL, or returns 404 if none is set.
## Configuration
Configuration is read from the repo-root `.env` (or `.env.local`). Only
`DATABASE_URL` is required. Every variable is documented in
[`.env.example`](.env.example). The ones you are most likely to change:
| Variable | Default | Description |
| ---------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | — | Postgres connection string. |
| `LINQ_DB_SCHEMA` | `public` | The schema linq owns, created if missing. Set it when sharing a database. |
| `LINQ_PORT` | `3000` | Port for the API and redirect server. |
| `LINQ_CLIENT_PORT` | `3001` | Port for the `dev:client` server. |
| `LINQ_CLIENT_BASE_PATH` | `/home` | Where a combined deployment mounts the UI. |
| `LINQ_APP_HOST` | — | Gives the UI a host of its own. See [UI on its own host](#ui-on-its-own-host). |
| `LINQ_DEFAULT_DOMAIN` | `localhost:${LINQ_PORT}` | Seeds the first domain on an empty database only. |
| `LINQ_REDIS_URL` | — | Moves the redirect cache to Redis. Must be reachable at boot. See `resources/docs/adr/0009`. |
| `LINQ_CACHE_TTL` | `300` | How many seconds a cached lookup lives. |
| `LINQ_FETCH_LINK_METADATA` | `false` | Fetches a destination's title and icon. Enable only with egress rules that block private addresses. |
| `LINQ_API_RATE_LIMIT_PER_MINUTE` | `1000` | Per-key API rate limit. |
| `LINQ_VISIT_MAX_PENDING` | `1000` | Maximum queued analytics writes. |
Logs are written to `./data/logs/linq.log` and rotated. In Docker this is the
`/data` volume.
## Deployment
Postgres is always external to linq, and so is Redis if you use it.
### Docker
The published image is `linq`, available as
[`labsatquicko/linq`](https://hub.docker.com/r/labsatquicko/linq) and
`ghcr.io/org-quicko/linq`.
| You want | Image | UI at | Required |
| -------------------------- | --------------------------------------------------------- | -------------------------- | ------------------------------------------------------------ |
| UI on its own domain | `linq` (published) | `/` on `LINQ_APP_HOST` | `DATABASE_URL`, `LINQ_DEFAULT_DOMAIN`, `LINQ_APP_HOST` |
| UI next to the short links | `Dockerfile` built with `LINQ_CLIENT_BASE_PATH=/home` | `/home` on every host | `DATABASE_URL`, `LINQ_DEFAULT_DOMAIN` |
| API only | `Dockerfile.server` | — | `DATABASE_URL`, `LINQ_DEFAULT_DOMAIN` |
| UI only | `Dockerfile.client` | `/` | — |
`docker/examples/docker-compose/` has a Compose stack for each combination:
bundled or external Postgres, with or without Redis, with or without Caddy, and
split client/server setups:
```sh
docker compose -f docker/examples/docker-compose/01-bundled-postgres.yml up --build -d
```
See [`docker/examples/README.md`](docker/examples/README.md) for details on
every stack.
> [!IMPORTANT]
> The UI base path is fixed when the image is built. After changing
> `LINQ_CLIENT_BASE_PATH`, rebuild the image. Changing only the container's
> environment leaves the UI and the server using different paths.
### UI on its own host
```dotenv
LINQ_APP_HOST=linq.example.com # UI at /, API at /api/*
LINQ_DEFAULT_DOMAIN=link.example.com # short links at link.example.com/
```
The UI answers only on the app host. Every other host serves short links, and
`/api/*` answers on all hosts. The app host must differ from every registered
domain. Behind a reverse proxy, route both hosts to the same port and preserve
the `Host` header. See `resources/docs/adr/0019`.
### Static hosting
`bun run build:client:standalone` writes `apps/client/out-standalone`, which you
can host on Vercel, Netlify, S3, nginx or similar. No API URL is compiled in, so
one deployment can manage any number of linq instances. Build it from a full
checkout, and make sure the host serves `x/index.html` for `/x/`.
### Automatic HTTPS
Use a Caddy Compose example (3, 4, 7 or 8). When `LINQ_CADDY_ADMIN_URL` is set,
linq pushes every domain change to Caddy. Once a domain's DNS points at the
host, adding the domain in linq is enough to serve it over HTTPS. See
`resources/docs/adr/0012`.
The Caddy examples do not publish linq's port 3000 to the host. Caddy is the only
public entry point, so API keys never travel over a plaintext bypass.
## Development
```bash
bun run test # server and client test suites
bun run typecheck # tsc -b --force
bun run lint # biome check .
bun run format # biome check --write .
bun run build:client # UI export served by linq
bun run build:client:standalone # UI export for a static host
bun run db:migrate # apply pending migrations
bun run db:codegen # regenerate Kysely types
```
Contributors and coding agents should read [`AGENTS.md`](AGENTS.md) first.
## Documentation
- [`CONTEXT.md`](CONTEXT.md): the domain vocabulary. Read it before the code.
- [`resources/docs/architecture.md`](resources/docs/architecture.md): how the pieces fit together.
- [`resources/docs/repo-map.md`](resources/docs/repo-map.md): where everything lives.
- [`resources/docs/adr/`](resources/docs/adr/): architecture decision records.
- [`resources/openapi/linq.openapi.json`](resources/openapi/linq.openapi.json): the REST API spec.