ushort
ushort is a compact URL shortener written in Rust and backed by SQLite. It is
a behavior-compatible replacement for the original stdlib-only Python service:
the routes, base-path handling, response shapes, database schema, retention,
API-key checks, throttling, redirects, and frontend are preserved.
The frontend under static/ is embedded into the executable at compile time.
The final Linux image contains only one statically linked executable and runs
without Python, a shell, a package manager, or external static files.
Quick start
cp config.example.toml config.toml
# Edit base_url and api_key, then:
docker compose up --build -d
The service listens on 127.0.0.1:18082 on the host. Its SQLite database is
stored at ./data/urlshort.db and survives container replacement.
The container runs as UID/GID 1001:1001 by default. Override
USHORT_UID/USHORT_GID if needed, and ensure ./data is writable by that
identity.
Configuration
TOML is the preferred format; see config.example.toml. Existing JSON files
remain supported, including the old short_length alias for
min_short_length.
ushort config.toml
ushort legacy-config.json
| Key | Required | Default | Description |
|---|---|---|---|
base_url |
yes | — | Public URL. Its path becomes the routing prefix. |
api_key |
yes | — | Secret used by list and delete operations. |
host |
no | 0.0.0.0 |
Bind address. |
port |
no | 8080 |
Bind port. |
db_path |
no | data/urlshort.db |
SQLite database path. |
retention_days |
no | 0 |
Default lifetime for new URLs; zero never expires. |
min_short_length |
no | 6 |
Initial generated code length. |
max_short_length |
no | 32 |
Maximum generated and accepted code length. |
max_url_length |
no | 2048 |
Maximum original URL length. |
max_retention_days |
no | 3650 |
Maximum per-URL retention value. |
rate_limit_requests |
no | 60 |
Requests allowed per client/window. |
rate_limit_window |
no | 60 |
Sliding-window length in seconds. |
production |
no | false |
When true, disable embedded frontend routes for compatibility with deployments that serve static files separately. |
For example, base_url = "https://example.com/go/links" limits routing to
/go/links and produces URLs such as
https://example.com/go/links/aB3xYz. Requests outside that prefix return 404.
Real API keys belong only in the deployment's ignored config.toml, never in
the repository or container image.
API
All paths below are relative to the path in base_url. Trailing slashes are
accepted. Every response includes the legacy CORS and security headers.
Health and frontend
GET /serves the byte-identical embedded frontend whenproduction=false.GET /api/healthreturns HTTP 200:
{"status":"ok","service":"url-shortener"}
When the configured base path is non-empty, a GET of the bare path (for
example /s) redirects to /s/.
Create a short URL
POST /api/shorten and GET /api/shorten are both supported and do not
require an API key. Fields may be supplied as query parameters or in a JSON
body; query parameters take precedence.
| Field | Required | Description |
|---|---|---|
url |
yes | Absolute http:// or https:// URL. |
retention_days |
no | Per-URL lifetime, or zero to keep forever. |
Success is HTTP 201 with only the short URL as UTF-8 plain text:
https://example.com/s/aB3xYz
Codes begin at min_short_length. After ten collisions at one length the
service tries the next length, through max_short_length.
List URLs
GET /api/urls?api_key=<key> returns all rows newest first. A missing or
invalid key returns HTTP 403 with an empty body.
{
"count": 1,
"urls": [
{
"short_code": "aB3xYz",
"short_url": "https://example.com/s/aB3xYz",
"original_url": "https://example.org",
"created_at": 1710000000,
"visit_count": 5,
"retention_days": 30
}
]
}
Metadata and lookup
GET /api/urls/<code>returns one metadata object without authentication.GET /api/lookup?url=<encoded-url>returns the newest matching metadata object without authentication.- A missing record returns HTTP 404 with an empty body.
Delete
DELETE /api/urls/<code>?api_key=<key> returns:
- HTTP 204 with an empty body on success;
- HTTP 404 with an empty body when the code does not exist; or
- HTTP 403 with an empty body when authentication fails.
Redirect
GET /<code> atomically increments visit_count and responds with HTTP 302 to
the stored original URL.
Data compatibility
The existing database can be mounted directly; no export/import is required. The schema remains:
CREATE TABLE urls (
short_code TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
created_at INTEGER NOT NULL,
visit_count INTEGER NOT NULL DEFAULT 0,
retention_days INTEGER NOT NULL DEFAULT 0
);
Older databases missing retention_days are upgraded in place with a default
of zero. Expired records are removed on startup and before each GET, POST, or
DELETE request, using the original strict expiry boundary.
Before a production cutover, make a filesystem-consistent backup of the database (or use SQLite's backup command) and test the copy with the new image.
Frontend
The existing index.html, JavaScript, CSS, locally hosted fonts, sorting,
pagination, theme selection, URL lookup, copy/delete actions, and admin flow
are unchanged. Compile-time embedding makes those files part of the standalone
artifact; no bind mount or separate web root is needed.
For the existing /s deployment, place the location blocks from nginx.conf
in the public vhost. They proxy all traffic to the binary while retaining the
current no-store HTML and no-cache asset policies.
Build and test
The repository intentionally does not require a host Rust installation.
docker build -t ushort:local .
docker compose up -d
Run the Rust test suite in an isolated build stage:
docker build --target tester .
The Dockerfile builds against musl and uses scratch for the runtime image.
Docker Buildx can publish both required architectures from the same source:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t sodium/ushort:latest \
--push .
Security model
- SQL statements use bound parameters.
- Short codes are restricted to ASCII alphanumerics.
- URL, retention, code, and request-body sizes are bounded.
- API-key comparison is constant-time; protected operations return no body on authentication failure.
- Per-IP sliding-window throttling applies to API calls and redirects; embedded
frontend files do not consume API quota. The app uses
X-Real-IP, then the firstX-Forwarded-Forvalue, then the TCP peer. Keep the container port bound to loopback and let the trusted reverse proxy overwrite those headers. - CORS preflight uses
OPTIONS; it does not consume rate-limit quota. - The runtime image is read-only, drops Linux capabilities, and runs as a numeric non-root user through Compose.
SIGINT/SIGTERMcleanly unblock the server for prompt container shutdown.