URL Shortener
A minimal URL shortener written in pure Python 3.8+ (zero third-party dependencies) backed by SQLite.
Quick start
Run locally
python urlshort.py config.json
Run with Docker Compose
Edit config.json first (especially api_key and base_url), then:
docker compose up --build
The SQLite database is stored in ./data/urlshort.db on the host — it survives container restarts.
Configuration (config.json)
| Key | Required | Default | Description |
|---|---|---|---|
base_url |
✅ | — | Public base URL. The path component (e.g. /s in http://example.com/s) is automatically used as the server's routing prefix. |
api_key |
✅ | — | Secret key to protect write/read operations |
host |
"0.0.0.0" |
Bind address | |
port |
8080 |
Bind port | |
db_path |
"data/urlshort.db" |
Path to the SQLite database file | |
retention_days |
0 |
Default retention period in days for new URLs. 0 means never expire. |
|
min_short_length |
6 |
Minimum character length for generated short codes | |
max_short_length |
32 |
Maximum character length for generated short codes | |
max_url_length |
2048 |
Maximum allowed length for original URLs | |
max_retention_days |
3650 |
Maximum allowed retention_days value per URL | |
rate_limit_requests |
60 |
Max requests per IP per rate-limit window | |
rate_limit_window |
60 |
Rate-limit window in seconds |
Backward compat: If an old config contains
short_length, it is automatically used asmin_short_length.
Deploying behind a sub-path (e.g. http://example.com/s)
Just set base_url to include the desired path prefix — the server derives its routing prefix automatically from it:
{
"base_url": "http://example.com/s",
...
}
The path component /s is extracted at startup. The server will only respond to requests whose path starts with /s; everything else returns 404.
base_url |
Derived routing prefix | Short URL example |
|---|---|---|
http://example.com |
(none — root) | http://example.com/aB3xYz |
http://example.com/s |
/s |
http://example.com/s/aB3xYz |
http://example.com/go/links |
/go/links |
http://example.com/go/links/aB3xYz |
With Docker Compose
# Set base_url in config.json, then:
docker compose up --build
# Service is now available at http://localhost:8080/s/
With an existing nginx vhost
nginx.conf contains location blocks only — drop them into an existing server { } block.
The app handles the base_path prefix internally; nginx proxies API/redirect requests and serves frontend static files.
Browser ──► nginx /s/ ──► static/index.html
Browser ──► nginx /s/static/… ──► static files (CSS/JS)
Browser ──► nginx /s/api/… ──► urlshort :8080 (proxy)
Browser ──► nginx /s/<code> ──► urlshort :8080 (proxy → 302)
To change the prefix, update base_url in config.json and the location /s blocks in nginx.conf.
Authentication
All endpoints marked with 🔒 require the API key.
Pass it as a query parameter or in the JSON request body:
?api_key=<api_key>
# or in JSON body
{"api_key": "<api_key>", ...}
Retention
URLs can have a retention period (retention_days). When set to a positive integer, the URL will be automatically deleted after that many days. If 0 or not set, the URL never expires.
- The default retention is set in
config.json(retention_dayskey, default0). - Each URL can override the default at creation time via the
retention_daysfield. - Expired URLs are cleaned up on startup and lazily on each incoming request.
API Reference
GET /
Serves the frontend page (if static/index.html exists), otherwise returns health check JSON.
GET /api/health
Health check endpoint.
Response 200
{ "status": "ok", "service": "url-shortener" }
POST / GET /api/shorten
Create a new short URL (supports both POST and GET). No API key required.
Fields can be passed as query parameters (URL-encoded) or in a JSON request body. Query parameters take precedence over body fields.
| Field | Required | Description |
|---|---|---|
url |
✅ | The URL to shorten (must start with http:// or https://) |
retention_days |
Override the default retention period for this URL |
Short code length is determined automatically: the server starts at min_short_length and
progressively tries longer codes on collision, up to max_short_length.
Response 201 — plain text containing only the short URL:
http://localhost:8080/s/aB3xYz
GET /api/urls 🔒
List all short URLs, newest first.
Response 200
{
"count": 2,
"urls": [
{
"short_code": "aB3xYz",
"short_url": "http://localhost:8080/s/aB3xYz",
"original_url": "https://example.com",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 0
}
]
}
GET /api/urls/<code>
Get metadata for a single short code. No API key required.
Response 200
{
"short_code": "aB3xYz",
"short_url": "http://localhost:8080/s/aB3xYz",
"original_url": "https://example.com",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 0
}
Response 404 — code not found (empty body).
GET /api/lookup
Look up a URL by its original URL. No API key required.
Used by the frontend to check if a URL has already been shortened.
| Parameter | Required | Description |
|---|---|---|
url |
✅ | The original URL to look up |
Response 200 — same metadata JSON as GET /api/urls/<code>.
Response 404 — no short URL exists for this original URL.
DELETE /api/urls/<code> 🔒
Delete a short URL entry.
Response 204 — success (empty body).
Response 404 — code not found (empty body).
Response 403 — not authorized (empty body).
GET /<code>
Redirect to the original URL (HTTP 302).
Increments visit_count on each hit.
Example with curl
# Shorten a URL (POST with JSON body)
curl -X POST http://localhost:8080/s/api/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://github.com"}'
# Shorten a URL (POST with query parameters)
curl -X POST "http://localhost:8080/s/api/shorten?url=https%3A%2F%2Fgithub.com&retention_days=30"
# Shorten a URL (GET with query parameters)
curl "http://localhost:8080/s/api/shorten?url=https%3A%2F%2Fgithub.com"
# Follow the redirect
curl -L http://localhost:8080/s/aB3xYz
# Get metadata for a short URL (no API key needed)
curl http://localhost:8080/s/api/urls/aB3xYz
# List all URLs
curl "http://localhost:8080/s/api/urls?api_key=change-this-secret-key"
# Delete a URL
curl -X DELETE "http://localhost:8080/s/api/urls/aB3xYz?api_key=change-this-secret-key"
# Lookup by original URL
curl "http://localhost:8080/s/api/lookup?url=https%3A%2F%2Fexample.com"
Frontend
A clean single-page frontend is included in static/. It provides:
- URL shortening — paste a URL and press Enter to create a short URL
- Existing URL lookup — as you type a URL, the frontend checks if it already exists and shows its metadata
- Admin table — enter a valid API key to see all shortened URLs in a sortable, paginated table
- Copy & Delete — per-row copy and delete buttons (delete on hover only)
- Theme switching — matches system dark/light preference, with a manual toggle
Accessing the frontend
- Via nginx (production): browse to
/s/ - Via Python backend (local dev): browse to
http://localhost:8080/s/
Fonts
The CSS includes a Google Fonts @import that works out of the box. For strict local-serve deployments, replace it with locally-hosted font files (use navpage's fetch_fonts.py as a reference).
Security & Hardening
CORS
All responses include Access-Control-Allow-Origin: * headers. OPTIONS preflight requests are handled automatically.
Rate limiting
A per-IP sliding-window rate limiter protects all endpoints. Default: 60 requests per 60-second window (configurable via rate_limit_requests and rate_limit_window). Behind a reverse proxy, the real client IP is extracted from X-Real-IP / X-Forwarded-For headers. Returns 429 Too Many Requests when exceeded.
Field validation
All limits are configurable via config.json.
| Field | Constraint (defaults) |
|---|---|
url |
Max max_url_length (2048) chars, valid http(s) |
retention_days |
0–max_retention_days (3650) |
short_code |
Alphanumeric only, max max_short_length (32) chars |
Security headers
Every response includes: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block.
SQL injection prevention
All database queries use parameterized statements (? placeholders).
Nginx configuration
nginx.conf contains location blocks only — drop them into an existing server { } block. Adjust the alias paths to match your deployment layout.
When running without nginx (Docker Compose or local dev), the Python backend serves the frontend directly at the base URL. Set "production": true in config to disable backend static serving (nginx handles it).
Caching strategy
The nginx config uses a layered caching strategy designed for deployments behind Cloudflare or other CDNs:
| Resource | Cache-Control |
Why |
|---|---|---|
/s/ (index.html) |
no-store |
Never cached by CDN or browser. This is the HTML entry point (~2KB) that contains ?v= cache-buster query strings for CSS/JS. Must always be fresh so that version bumps take effect immediately. |
/s/static/*.css?v=… |
no-cache |
Cached but revalidated on each request. The ?v= query string acts as a cache key — Cloudflare and browsers treat each version as a distinct resource. Bump the ?v= value in index.html whenever CSS/JS files change. |
/s/static/*.js?v=… |
no-cache |
Same as CSS. |
/s/api/*, /s/<code> |
(proxied) | Not cached by nginx; backend controls caching via response headers. |
Updating static files
When you modify style.css or app.js:
- Deploy the updated files to the server
- Bump the
?v=value instatic/index.html(e.g.?v=20260318→?v=20260319) - Reload nginx (
nginx -s reload)
Since index.html has no-store, browsers and Cloudflare always fetch the latest version, which in turn references the new ?v= URLs for CSS/JS — busting all downstream caches automatically. No manual CDN purge is needed.