Shortens
One URL in, a 12 character code out, 302 to the target.
Self-hosted · Free for noncommercial use
URL shortener and QR code generator you run yourself.
One container. No accounts, no tracking, every link expires.
docker run -d -p 8080:8080 dawidzbinski/shortr:latest
One URL in, a 12 character code out, 302 to the target.
The same short link as a QR, copied to the clipboard as a PNG.
Every link dies after URL_TTL, 30 days by default. Nothing to prune.
No sign-up, no analytics, no cookies. Everything noindex, crawlers get 403.
A ~15 MB distroless image for amd64, arm64 and armv7. A mounted volume or Redis keeps the links; neither is required.
Your domain, your storage, your logs. Free for noncommercial use.
docker run -d --name shortr \
-p 8080:8080 \
dawidzbinski/shortr:latest
Serves http://localhost:8080. Links are held in the process and lost on
restart — fine for a trial. To keep them, mount a volume and switch the adapter:
docker run -d --name shortr \
-p 8080:8080 \
-v shortr-data:/data \
-e URL_PERSISTER=file \
dawidzbinski/shortr:latest
That is the whole story for one container. Run more than one and they need Redis.
services:
shortr:
image: dawidzbinski/shortr:latest
restart: unless-stopped
ports:
- '8080:8080'
environment:
BASE_URL: https://s.example.com
URL_PERSISTER: redis
REDIS_HOST: redis
REDIS_PORT: '6379'
REDIS_USER: default
REDIS_PASSWORD: change-me
depends_on:
redis:
condition: service_healthy
redis:
image: redis:8-alpine
restart: unless-stopped
command: ['redis-server', '--requirepass', 'change-me', '--appendonly', 'yes']
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', '-a', 'change-me', 'ping']
interval: 5s
timeout: 3s
retries: 20
volumes:
redis-data:
Then docker compose up -d.
| Concern | What to do |
|---|---|
| Public origin | Set BASE_URL to the URL your users see. Short links and link previews are built from it. |
| TLS | Terminate at your proxy. shortr speaks plain HTTP on PORT. |
| Behind a proxy | Set TRUSTED_PROXIES to the proxy's CIDRs, or X-Forwarded-For is ignored and every client shares one rate-limit bucket. |
| Health checks | Probe GET /healthz from outside the container — the image has no shell. |
| Persistence | One container: URL_PERSISTER=file and a volume on /data. More than one: URL_PERSISTER=redis. The default memory adapter keeps nothing across a restart. |
| Rate limits | RATE_LIMIT_MODE and RATE_LIMIT_VALUE, per client IP, on /api only. |
| Upgrades | docker compose pull && docker compose up -d. |
| Tag | Moves |
|---|---|
latest | on every release |
1 | on every release in major 1 |
1.2 | on every patch of 1.2 |
1.2.3 | never |
Pin 1 to take fixes and features but approve majors yourself.
Every setting is an environment variable read at startup. An invalid value aborts the start with an error naming the variable; an empty one falls back to the default.
| Variable | Default | Rules |
|---|---|---|
BASE_URL | http://localhost:8080 | Public origin for short links and Open Graph tags. http or https with a host; trailing slashes stripped. |
PORT | 8080 | 1–65535. |
URL_TTL | 30d | Link lifetime. Go duration syntax plus an Nd day suffix. Greater than zero. |
URL_MAX_LENGTH | 4096 | Longest target URL in bytes, 256–65536. The body cap follows it, plus 1024 bytes. |
URL_PERSISTER | memory | Storage adapter: memory, file or redis. |
RATE_LIMIT_MODE | day | second, minute, hour or day. |
RATE_LIMIT_VALUE | 30 | Requests per window per client IP. At least 1. |
TRUSTED_PROXIES | empty | Comma separated CIDRs. Empty means X-Forwarded-For is ignored. |
LOG_LEVEL | info | debug, info, warn or error. JSON on stdout. |
STATIC_DIR | ./apps/client/dist | Where the built client lives. The image ships with /srv/client; leave it alone. |
memoryNo variables. Links live in the process: they vanish on restart and are not shared between replicas.
file| Variable | Default | Rules |
|---|---|---|
FILE_PATH | /data/shortr.db | Embedded database file, created 0600. Missing directories are created; the image ships /data owned by the container user. |
Links live in an embedded bbolt database at
FILE_PATH, one file on disk. A lookup reads a single key from its B+tree, so the
link set is not held in memory and stays flat however many links exist. Each shorten is one
transaction, durable the moment it returns, so a SIGKILL loses nothing. Expiry
is checked on every read and a background sweep drops expired keys each minute. The database
opens under an exclusive lock — exactly one process may hold it, so it is not shared between
replicas; use redis for more than one. Mount a volume on the directory so the
file outlives the container.
redis| Variable | Default | Rules |
|---|---|---|
REDIS_HOST | required | Non-empty host name. |
REDIS_PORT | required | 1–65535. |
REDIS_USER | required | Non-empty user name. |
REDIS_PASSWORD | required | Non-empty. Kept untrimmed, never logged. |
REDIS_DB | 0 | 0 or greater. |
Keys are written as url:<code> with SET NX EX, so Redis
owns expiry.
POST /api/shorten
Content-Type: application/json. Rate limited and bot filtered.
201 with the code, the short URL and an RFC 3339
expiresAt.
curl -X POST http://localhost:8080/api/shorten \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/a-rather-long-link"}'
{
"code": "aB3xY7kQ1mZp",
"shortUrl": "http://localhost:8080/aB3xY7kQ1mZp",
"expiresAt": "2026-10-05T12:00:00Z"
}
Errors share one shape, {"error": "…", "message": "…"}:
400 invalid_body, 400 invalid_url,
403 forbidden, 413 body_too_large,
415 unsupported_media_type, 429 rate_limited,
500 internal.
GET /{code}
302 to the target with Cache-Control: no-store. Neither
rate limited nor bot filtered. 404 when the code is unknown, expired, or
not 12 characters of [0-9A-Za-z] — a styled HTML page for a browser (any
client whose Accept lists text/html), the
not_found JSON shape for everyone else.
GET /healthz
200 {"status":"ok"} when the adapter answers a ping,
503 unavailable when it does not. Never bot filtered.
GET /, /robots.txt,
/favicon.svg, /favicon.png,
/apple-touch-icon.png, /og.png, /assets/*
The client, served from STATIC_DIR with a one hour max-age
on assets. / gets BASE_URL substituted into its Open Graph
tags. All bot filtered.
One page, two modes — Shorten URL and QR code. No external requests at runtime.
localStorage under shortr.mode. Roving tabindex: Left and Right move, Home and End jump to the ends.ClipboardItem. Clicking it copies again.expiresAt: Expires in 30 days · 4 Oct 2026.role="alert" element, status code first; a 429 shows the seconds to wait./assets/, so nothing is fetched from a CDN.[0-9A-Za-z] from crypto/rand without modulo bias — around 71 bits, so links cannot be enumerated.URL_TTL. Redis expires its own keys; the memory and file adapters check expiry on every read and sweep the expired ones in the background.X-Robots-Tag: noindex, nofollow, noarchive on every response, a robots.txt that disallows everything, and 403 for known crawler user agents. Redirects and /healthz stay open.default-src 'self', no inline scripts or styles, frame-ancestors 'none'), nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer.URL_MAX_LENGTH, bodies at that plus 1024 bytes. URLs with whitespace, control characters or embedded credentials are rejected./api only; redirects are never limited.A Go 1.27 API on Fiber v3 serves the JSON endpoints, the redirects and a plain TypeScript client bundled by esbuild — one binary, one image.
gcr.io/distroless/static-debian12:nonroot: no shell, no package manager, no toolchain. Just the static binary and the client in /srv/client.SaveURL, FindURL, Ping, Close. A new backend touches adapters/<name>.go, its test and one case in adapters.New, and reads its own environment variables.task project:start brings up the API with hot reload, the client watcher and Redis. task project:test, task project:lint and task project:e2e do the rest.main that passes CI is tagged and pushed to Docker Hub, with the version taken from the commit subjects.Full details in the README.