A simple, self-hosted URL shortener with a Rust CLI client and HTTP API server.
- Shorten URLs - Convert long URLs into short links
- Custom Codes - Choose your own short code or let the server generate one
- Expiration (TTL) - Set how long links should last (5 minutes to 30 days)
- Visit Analytics - Per-link visit stats: total counts, country/referrer breakdown, daily trends, and recent visits
- HTTP API - Simple REST API for integration
- CLI Tool - Easy-to-use command-line interface
- SQLite - Lightweight database with no external dependencies
- Docker Support - Multi-stage Dockerfile for easy deployment
- Automated Releases - Version file-based CI/CD workflow
- Multi-Platform - Binaries for Linux, macOS, and Windows
- Continuous Testing - Automated unit tests and linting
cutl/
├── server/ # HTTP API server (axum + SQLite)
│ ├── src/
│ │ ├── main.rs # Entry point
│ │ ├── config.rs # Configuration management
│ │ ├── models.rs # Data models
│ │ ├── database.rs # Database operations
│ │ ├── handlers.rs # HTTP handlers
│ │ └── utils.rs # Utilities (validation, code generation)
│ ├── Dockerfile # Multi-stage Docker build
│ └── Cargo.toml
├── cli/ # CLI client tool
│ ├── src/
│ │ ├── main.rs # Entry point
│ │ ├── config.rs # Configuration
│ │ ├── client.rs # API client
│ │ ├── output.rs # Output formatting
│ │ └── validation.rs # Input validation
│ └── Cargo.toml
├── docker-compose.yml # Docker Compose configuration
├── .github/
│ └── workflows/
│ ├── release.yml # Automated release workflow
│ └── test.yml # CI/CD test workflow
├── version/
│ └── version # Version file for releases
├── Makefile # Convenience commands
├── schema.sql # Database schema reference
├── Cargo.toml # Workspace configuration
└── README.md # This file
Download and install the latest release with a single command:
curl -fsSL https://raw.githubusercontent.com/ragilhadi/cutl/master/install-from-release.sh | bashOr download and inspect the script first:
curl -fsSL https://raw.githubusercontent.com/ragilhadi/cutl/master/install-from-release.sh -o install.sh
chmod +x install.sh
./install.shirm https://raw.githubusercontent.com/ragilhadi/cutl/master/install-from-release.ps1 | iexOr download and inspect the script first:
Invoke-WebRequest -Uri https://raw.githubusercontent.com/ragilhadi/cutl/master/install-from-release.ps1 -OutFile install.ps1
.\install.ps1The installer will:
- Detect your OS and architecture automatically
- Download the appropriate binary from the latest GitHub release
- Install to
~/.local/bin/cutlon Linux/macOS or%LOCALAPPDATA%\cutl\binon Windows - Make the binary executable
Supported platforms:
- Linux (x86_64, aarch64)
- macOS (x86_64, aarch64/Apple Silicon)
- Windows (x86_64)
If you prefer to build from source or need a custom build:
git clone https://github.com/ragilhadi/cutl.git
cd cutl
./install.shThis will build the CLI locally and install it to ~/.local/bin.
- For GitHub Release: None! Just download and run
- For Docker: Docker and Docker Compose
- For Source Build: Rust 1.83 or later
Using docker-compose:
# Build and start the container
docker-compose up -d
# View logs
docker-compose logs -f
# Stop the container
docker-compose downOr using the Makefile:
make build # Build the Docker image
make run # Start the container
make logs # View logs
make stop # Stop the containerBuild both the server and CLI:
cargo build --releaseThe compiled binaries will be at:
target/release/cutl-servertarget/release/cutl
Using Docker:
Edit docker-compose.yml to configure your environment variables, then:
docker-compose up -dNative:
- Basic usage (default settings):
./target/release/cutl-serverThis will:
- Use
sqlite:cutl.dbas the database - Listen on
0.0.0.0:3000 - Use
https://cutl.my.idas the base URL
- With environment variables:
export DATABASE_URL="sqlite:/path/to/database.db"
export BASE_URL="https://your-domain.com"
export BIND_ADDRESS="0.0.0.0:8080"
export AUTH_TOKEN="your-secret-token" # Optional
./target/release/cutl-server- Using a
.envfile:
Copy the example file:
cp server/.env.example server/.envEdit server/.env:
DATABASE_URL=sqlite:cutl.db
BASE_URL=https://cutl.my.id
BIND_ADDRESS=0.0.0.0:3000
AUTH_TOKEN=optional-secret-token
# Optional: path to GeoLite2-City.mmdb for IP geolocation in analytics
# GEOIP_DB_PATH=/path/to/GeoLite2-City.mmdbThen run:
./target/release/cutl-server- Basic usage:
./target/release/cutl https://example.comOutput:
✓ Short URL created
Short URL: https://cutl.my.id/abc123
Code: abc123
Expires: 2026-02-13 12:00:00 +00:00
- With custom TTL:
./target/release/cutl https://example.com --ttl 3d- With custom code:
./target/release/cutl https://example.com --code mylink- With both custom code and TTL:
./target/release/cutl https://example.com --code docs --ttl 7d- Using a custom server:
export CUTL_SERVER="https://your-cutl-instance.com"
./target/release/cutl https://example.comOr:
./target/release/cutl https://example.com --server https://your-cutl-instance.com- With authentication:
export CUTL_TOKEN="your-secret-token"
./target/release/cutl https://example.comCreates a new short link.
Request Headers (optional):
Authorization: Bearer <TOKEN>
Request Body:
{
"url": "https://example.com",
"code": "optional_custom_code",
"ttl": "3d"
}Response (200 OK):
{
"code": "abc123",
"short_url": "https://cutl.my.id/abc123",
"expires_at": 1760000000
}Error Responses:
400 Bad Request- Invalid URL, code, or TTL401 Unauthorized- Invalid or missing auth token409 Conflict- Code already exists500 Internal Server Error- Server error
Redirects to the original URL and records a visit row (IP, user-agent, referrer, geo data if configured).
Response:
302 Found- Redirects tooriginal_url404 Not Found- Link doesn't exist or has expired
Returns visit statistics for a short link.
Request Headers (optional, required when AUTH_TOKEN is set):
Authorization: Bearer <TOKEN>
Response (200 OK):
{
"code": "abc123",
"original_url": "https://example.com",
"created_at": 1739000000,
"expires_at": 1760000000,
"total_visits": 42,
"countries": [
{ "value": "ID", "count": 30 },
{ "value": "US", "count": 8 },
{ "value": null, "count": 4 }
],
"referers": [
{ "value": "https://twitter.com/", "count": 15 },
{ "value": null, "count": 27 }
],
"daily": [
{ "date": "2026-02-18", "count": 10 },
{ "date": "2026-02-17", "count": 32 }
],
"recent_visits": [
{
"visited_at": 1739900000,
"ip": "1.2.3.4",
"country": "ID",
"city": "Jakarta",
"user_agent": "Mozilla/5.0 ...",
"referer": null
}
]
}recent_visits: last 20 visits, newest firstdaily: last 30 days, newest first
Error Responses:
401 Unauthorized- Invalid or missing auth token (whenAUTH_TOKENis set)404 Not Found- Link doesn't exist or has expired
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
SQLite database path | sqlite:cutl.db |
BASE_URL |
Base URL for short links | https://cutl.my.id |
BIND_ADDRESS |
Address to bind to | 0.0.0.0:3000 |
AUTH_TOKEN |
Optional bearer token for API auth | (none) |
GEOIP_DB_PATH |
Path to GeoLite2-City.mmdb for IP geolocation | (none) |
RUST_LOG |
Log level (info/debug/trace) | (none) |
| Variable | Description | Default |
|----------|-------------|---------|-------|
| CUTL_SERVER | Server API URL | https://cutl.my.id |
| CUTL_TOKEN | Optional auth token | (none) |
Note: The CLI now defaults to https://cutl.my.id as the server. You can override this with:
--serverflag:cutl https://example.com --server http://localhost:3000CUTL_SERVERenvironment variable:export CUTL_SERVER="http://localhost:3000"
- Length: 1-32 characters
- Allowed characters: Letters (a-z, A-Z), numbers (0-9), hyphens (-), underscores (_)
- Pattern:
^[a-zA-Z0-9_-]{1,32}$
If no code is provided, the server generates a random base62 code (6-8 characters).
TTL specifies how long a link remains valid.
| Format | Description | Example |
|---|---|---|
5m |
Minutes | 5m = 5 minutes |
1h |
Hours | 1h = 1 hour |
3d |
Days | 3d = 3 days |
30d |
Days | 30d = 30 days |
Limits:
- Minimum: 5 minutes (300 seconds)
- Maximum: 30 days (2,592,000 seconds)
- Default: 7 days
CREATE TABLE links (
code TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_links_expires_at ON links(expires_at);
CREATE TABLE visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL REFERENCES links(code) ON DELETE CASCADE,
visited_at INTEGER NOT NULL, -- UNIX timestamp (seconds)
ip TEXT, -- raw IP address (IPv4 or IPv6)
country TEXT, -- ISO 3166-1 alpha-2 (e.g. "ID", "US")
city TEXT, -- city name, best-effort
user_agent TEXT, -- full User-Agent header value
referer TEXT -- Referer header value (nullable)
);
CREATE INDEX idx_visits_code ON visits(code);
CREATE INDEX idx_visits_visited_at ON visits(visited_at);Geo data: Country and city columns are populated only when
GEOIP_DB_PATHis set to a valid GeoLite2-City.mmdbfile. If unset, those columns remainNULLand analytics still works.
- Must start with
http://orhttps:// - Cannot point to
localhostor127.0.0.1 - URL format is validated before storage
To enable API authentication, set the AUTH_TOKEN environment variable on the server.
Docker:
environment:
- AUTH_TOKEN=your-secret-tokenNative:
export AUTH_TOKEN="your-secret-token"CLI:
export CUTL_TOKEN="your-secret-token"The CLI will automatically include the Authorization: Bearer <TOKEN> header.
The multi-stage Dockerfile creates a minimal production image:
- Build stage: Compiles the Rust application
- Runtime stage: Creates a minimal Debian image with just the binary
Features:
- Non-root user (
cutl) - Persistent volume for SQLite database
- Health checks
- Small image size
- Automatic permission handling via entrypoint script
Important: The Docker setup includes fixes for SQLite database permissions:
- Container runs as root initially
- Entrypoint script creates
/datadirectory with proper ownership - Switches to
cutluser before running the server - Database file is created with correct permissions automatically
Create /etc/systemd/system/cutl.service:
[Unit]
Description=cutl URL Shortener
After=network.target
[Service]
Type=simple
User=cutl
WorkingDirectory=/opt/cutl
Environment="DATABASE_URL=sqlite:/opt/cutl/cutl.db"
Environment="BASE_URL=https://cutl.my.id"
Environment="BIND_ADDRESS=0.0.0.0:3000"
Environment="AUTH_TOKEN=your-secret-token"
ExecStart=/opt/cutl/cutl-server
Restart=always
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl daemon-reload
sudo systemctl enable cutl
sudo systemctl start cutlserver {
listen 80;
server_name cutl.my.id;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}# Create a short link
curl -X POST https://cutl.my.id/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/very/long/path", "ttl": "7d"}'
# Get analytics for a short link
curl https://cutl.my.id/analytics/abc123curl -X POST https://cutl.my.id/shorten \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"url": "https://example.com"}'
# Analytics with auth
curl https://cutl.my.id/analytics/abc123 \
-H "Authorization: Bearer your-token"cargo test --workspacecargo clippy --workspacecargo fmt --allThis project uses GitHub Actions for automated testing and releases:
Runs on every push and pull request to master:
- ✅ Code formatting check (
cargo fmt --check) - ✅ Linting with Clippy (
cargo clippy) - ✅ Unit tests for CLI and Server
- ✅ Build verification
- ✅ Cargo caching for faster builds
Automatically creates releases when version/version file changes:
- ✅ Reads version from
version/versionfile - ✅ Checks for duplicate tags
- ✅ Creates git tag automatically
- ✅ Builds binaries for all platforms (Linux, macOS, Windows)
- ✅ Publishes GitHub release with binaries
Simply update the version file and push to master:
echo "v1.0.1" > version/version
git add version/version
git commit -m "Release v1.0.1"
git push origin masterGitHub Actions will automatically:
- Create a git tag
- Build binaries for all platforms
- Create a GitHub release
- Upload all binaries
See RELEASE.md and VERSION_WORKFLOW.md for detailed release documentation.
# For Linux x86_64
cargo build --release --target x86_64-unknown-linux-gnu
# For macOS ARM64 (Apple Silicon)
cargo build --release --target aarch64-apple-darwin
# For Windows
cargo build --release --target x86_64-pc-windows-gnuMIT
Contributions are welcome! Please feel free to submit a Pull Request.