Permanent short URLs with per-visit logging. A link is a slug — random or chosen — that 302-redirects to a target you control. When the target moves, you edit one line and every link you have ever shared keeps working. Each visit is logged with a timestamp, pseudonymous visitor identifier, location/request metadata, referrer, and user agent.
Stack: Python 3.12, FastAPI, Pydantic, SQLite, uv, and ruff.
git clone <this repo> && cd redirect
uv sync
cp .env.example .env # edit BASE_URL
cp links.example.toml links.toml
uv run --env-file .env uvicorn main:app --reload --port 8001curl -i http://localhost:8001/abc1234 answers 302. Visits land in
data/redirect.db. The app and CLI read the environment, not .env —
outside Docker, pass it with uv run --env-file .env ... (compose injects it
via env_file). Against a Docker deployment, run the CLI inside the
container: docker compose exec web uv run --no-sync python -m redirect.cli ....
Links live in links.toml — one [slug] table per short URL:
[abc1234]
target = "https://example.com/signup"
note = "march campaign"Slugs are 1–64 characters of a-z, 0-9, -; targets must be http:// or
https:// URLs. The app reads the file on every request, so editing a target
takes effect on the next request — no restart. A broken file fails requests
loudly until fixed.
Create links with the CLI, which generates a random 7-character slug from an
alphabet without l, o, 0, 1 and prints the URL, ready to share:
uv run --env-file .env python -m redirect.cli https://example.com/signup --note "march campaign"
# https://go.example.com/k3x9q2m → https://example.com/signupChoose your own slug with --slug wedding, or edit the file by hand. Two slugs
pointing at the same target get separate log entries — that is how you tell two
campaigns apart. Slugs match case-insensitively.
Incoming query strings are ignored; the slug alone identifies the link, so a crafted URL cannot inject parameters into the destination.
Every visit stores the timestamp, slug, target and note at the time of the
visit, a pseudonymous visitor identifier (a salted hash of the client IP — the
IP itself is never stored), country (CF-IPCountry, set by Cloudflare),
referrer, and user agent in data/redirect.db — a plain SQLite database you
can query directly. The salt lives in data/redirect.salt, generated on first
run; keep it with your backups, or unique-visitor counts restart. A headers
column stores the request headers as JSON, which captures everything Cloudflare
adds (CF-Ray, CF-Visitor, and — with the "Add visitor location headers"
Managed Transform enabled — CF-IPCity, CF-IPContinent,
CF-IPLatitude/Longitude, CF-IPTimeZone, and more) as well as browser
headers like Accept-Language, queryable later via SQLite's json_extract.
Cookie, Authorization, and the IP-carrying headers (CF-Connecting-IP,
X-Forwarded-For, X-Real-IP, True-Client-IP) are excluded from the stored
blob. The log grows by one row per visit with no retention cap —
prune it by hand if volume ever matters. The built-in /privacy page
(/privacy is a reserved slug) states this logging to visitors and shows a
contact address (OPERATOR_CONTACT, default privacy@example.com).
Docker Compose is the supported deployment path.
cp .env.example .env # set BASE_URL
cp links.example.toml links.toml
docker compose up -d --buildCompose binds the app to 127.0.0.1:8001. Put an HTTPS reverse proxy or
Cloudflare Tunnel in front of it. For a Cloudflare proxy, use Full (strict) TLS
with an origin certificate, or use a tunnel — Flexible mode carries traffic to
your origin as plaintext HTTP across the public internet.
Uvicorn runs with --proxy-headers and takes the client IP and scheme from
X-Forwarded-*. FORWARDED_ALLOW_IPS names the proxy hops trusted to set
those headers (default: loopback); the compose file sets the Docker network
ranges. On other hosting, set it to the address the platform's proxy connects
from. The client IP it resolves is what the visit log records.
The instance files — .env, links.toml, and data/ — are git- and
docker-ignored; keep the real ones in your private instance repo or on the
host. SQLite data lives in ./data/; copy that directory as part of your
backup routine.
git pull
docker compose up -d --buildBack up data/, .env, and links.toml before updates.
uv run pytest
uv run ruff check .
uv run ruff format .
uv run basedpyright