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 /api/shorten 🔒
Create a new short URL.
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 |
|---|---|---|
api_key |
✅ | API key for authentication |
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 (JSON body)
curl -X POST http://localhost:8080/s/api/shorten \
-H "Content-Type: application/json" \
-d '{"api_key": "change-this-secret-key", "url": "https://github.com"}'
# Shorten a URL (query parameters)
curl -X POST "http://localhost:8080/s/api/shorten?api_key=change-this-secret-key&url=https%3A%2F%2Fgithub.com&retention_days=30"
# 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 (requires API key)
- 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.