Self-hosted · Free for noncommercial use

shortr

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

Install Docker Hub GitHub

What it does

Shortens

One URL in, a 12 character code out, 302 to the target.

QR codes

The same short link as a QR, copied to the clipboard as a PNG.

Expires

Every link dies after URL_TTL, 30 days by default. Nothing to prune.

Stays private

No sign-up, no analytics, no cookies. Everything noindex, crawlers get 403.

Runs anywhere

A ~15 MB distroless image for amd64, arm64 and armv7. A mounted volume or Redis keeps the links; neither is required.

Yours

Your domain, your storage, your logs. Free for noncommercial use.

Install

Docker

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.

Docker Compose, with 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.

Self-hosting

Before you point a domain at it
ConcernWhat to do
Public originSet BASE_URL to the URL your users see. Short links and link previews are built from it.
TLSTerminate at your proxy. shortr speaks plain HTTP on PORT.
Behind a proxySet TRUSTED_PROXIES to the proxy's CIDRs, or X-Forwarded-For is ignored and every client shares one rate-limit bucket.
Health checksProbe GET /healthz from outside the container — the image has no shell.
PersistenceOne 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 limitsRATE_LIMIT_MODE and RATE_LIMIT_VALUE, per client IP, on /api only.
Upgradesdocker compose pull && docker compose up -d.
Image tags
TagMoves
lateston every release
1on every release in major 1
1.2on every patch of 1.2
1.2.3never

Pin 1 to take fixes and features but approve majors yourself.

Configuration

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.

Core

always read
VariableDefaultRules
BASE_URLhttp://localhost:8080Public origin for short links and Open Graph tags. http or https with a host; trailing slashes stripped.
PORT80801–65535.
URL_TTL30dLink lifetime. Go duration syntax plus an Nd day suffix. Greater than zero.
URL_MAX_LENGTH4096Longest target URL in bytes, 256–65536. The body cap follows it, plus 1024 bytes.
URL_PERSISTERmemoryStorage adapter: memory, file or redis.
RATE_LIMIT_MODEdaysecond, minute, hour or day.
RATE_LIMIT_VALUE30Requests per window per client IP. At least 1.
TRUSTED_PROXIESemptyComma separated CIDRs. Empty means X-Forwarded-For is ignored.
LOG_LEVELinfodebug, info, warn or error. JSON on stdout.
STATIC_DIR./apps/client/distWhere the built client lives. The image ships with /srv/client; leave it alone.

Storage — memory

default

No variables. Links live in the process: they vanish on restart and are not shared between replicas.

Storage — file

URL_PERSISTER=file
VariableDefaultRules
FILE_PATH/data/shortr.dbEmbedded 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.

Storage — redis

URL_PERSISTER=redis
VariableDefaultRules
REDIS_HOSTrequiredNon-empty host name.
REDIS_PORTrequired1–65535.
REDIS_USERrequiredNon-empty user name.
REDIS_PASSWORDrequiredNon-empty. Kept untrimmed, never logged.
REDIS_DB00 or greater.

Keys are written as url:<code> with SET NX EX, so Redis owns expiry.

API

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.

Client

One page, two modes — Shorten URL and QR code. No external requests at runtime.

Security

Technical

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.

Full details in the README.