Kutt sits in a useful middle ground.
It is not as cloud-native and infrastructure-heavy as Dub, but it is more polished than a bare redirect table. You get custom domains, private statistics, user/admin management, API access, OIDC support, themes, and Docker deployment options.
That makes it a sensible self-hosted URL shortener when you want a real app without the managed-service footprint of a full attribution platform.
What is Kutt?
Kutt is an open-source URL shortener.
I inspected commit:
279b491b53bbd01fbae70f603222526962772061
One important naming note: the current official domain is kutt.to. The upstream README warns that kutt.it is no longer owned by the maintainers.
What You Get
Kutt has the expected short-link basics:
- create short URLs;
- edit and delete links;
- custom short codes;
- link descriptions;
- password-protected links;
- expiration times;
- private statistics;
- custom domains;
- anonymous link controls;
- admin user/link management;
- REST API;
- browser integrations;
- theme and template overrides.
It also supports OpenID Connect, which is useful if you already run an identity provider.
Architecture
Kutt is a compact Node.js application.
The current stack is:
- Node.js / Express for the HTTP server.
- Handlebars for server-rendered pages.
- Knex for database migrations and queries.
- Passport for local login, JWT/API auth, and optional OIDC.
- SQLite by default.
- Postgres or MySQL/MariaDB as stronger production database options.
- Redis optionally for cache, queues, and rate-limit related paths.
- Nodemailer for verification, password reset, and report emails.
The main request shape is simple:
Browser/API client -> Express routes -> Knex database queries
-> optional Redis cache/queue
Short-link redirects eventually fall through to the /:id route after page and API routes have been checked.
Docker Options
Upstream includes several Compose files:
docker-compose.yml: SQLite only.docker-compose.sqlite-redis.yml: SQLite plus Redis.docker-compose.postgres.yml: Postgres plus Redis.docker-compose.mariadb.yml: MariaDB plus Redis.
For the reusable Home-Lab setup, I chose the Postgres plus Redis path. It is slightly heavier than SQLite, but it is the better default for a long-running self-hosted app.
That does not mean Postgres and Redis are mandatory. Kutt can run simpler than the Home-Lab snippet:
- SQLite only: simplest deployment; one Kutt container plus a persistent SQLite volume.
- SQLite + Redis: still simple, but adds Redis for cache/queue/rate-limit paths.
- Postgres + Redis: the Home-Lab default; better fit for a durable always-on deployment.
- MariaDB/MySQL + Redis: useful if your existing stack already standardizes on MySQL-compatible databases.
The important part is matching the environment variables to the stack. If you use SQLite, keep DB_CLIENT=better-sqlite3 and set DB_FILENAME. If you use Postgres, set DB_CLIENT=pg. If you want Redis to actually be used, set REDIS_ENABLED=true.
Home-Lab Compose Review
There was already a Kutt folder in Home-Lab.
It had two compose files:
docker-compose.yml, intended for Postgres and Redis;kutt_docker-compose.yml, an older SQLite/Redis variant.
The important issue: the Postgres/Redis compose did not set:
DB_CLIENT=pg
REDIS_ENABLED=true
Current Kutt defaults to:
DB_CLIENT=better-sqlite3
REDIS_ENABLED=false
So the old compose could start Postgres and Redis while Kutt still used SQLite and ignored Redis. I replaced it with a single canonical compose, removed hardcoded secrets and personal host paths, and added .env.sample.
Self-Hosting with Docker
The reusable Home-Lab compose file is here:
The site includes the same Compose file from the local snippets folder:
assets/snippets/kutt/docker-compose.yml
Pre-Requisites - Docker
Install Docker on your system before proceeding:
- Linux: Official Docker Engine install guide
- Windows / Mac: Docker Desktop
Verify installation: docker --version && docker compose version
version: "3"
services:
kutt:
image: kutt/kutt:latest
container_name: kutt
restart: always
depends_on:
- postgres
- redis
environment:
- NODE_ENV=production
- DEFAULT_DOMAIN=192.168.1.11:8788 #example.com # your public domain, no protocol
- SITE_NAME=Kutt
- DB_HOST=postgres
- DB_PORT=5432
- DB_NAME=kutt
- DB_USER=kutt
- DB_PASSWORD=change_me # change this
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=please_change_me # change this
# Optional email configuration (for password reset, verification, etc.)
# - MAIL_HOST=smtp.example.com
# - MAIL_PORT=587
# - MAIL_SECURE=false
# - MAIL_USER=smtp_user
# - MAIL_PASSWORD=smtp_password
# - MAIL_FROM=Kutt <[email protected]>
# Optional admin emails (comma separated)
# - [email protected]
ports:
- "8788:3000" # change host port if needed
postgres:
image: postgres:15
container_name: kutt-postgres
restart: always
environment:
- POSTGRES_DB=kutt
- POSTGRES_USER=kutt
- POSTGRES_PASSWORD=change_me # change this
volumes:
- /home/docker/kutt/postgres:/var/lib/postgresql/data
redis:
image: redis:7-alpine
container_name: kutt-redis
restart: always
command: ["redis-server", "--appendonly", "yes"]
volumes:
- /home/docker/kutt/redis:/data
Prepare the environment:
cd assets/snippets/kutt
cp .env.sample .env
Generate secrets:
openssl rand -base64 32
openssl rand -base64 32
Use one generated value for:
POSTGRES_PASSWORD
Use the other for:
JWT_SECRET
Start the stack:
docker compose up -d
The default local URL is:
http://localhost:8788
Domain Configuration
Kutt uses DEFAULT_DOMAIN to generate short links.
Use the hostname without protocol:
DEFAULT_DOMAIN=localhost:8788
DEFAULT_DOMAIN=192.168.1.2:8788
DEFAULT_DOMAIN=links.example.com
If you deploy behind HTTPS, set the public domain and put Kutt behind your reverse proxy:
https://links.example.com -> reverse proxy -> http://kutt:3000
For custom user domains, Kutt can display server IP/CNAME hints through:
SERVER_IP_ADDRESS
SERVER_CNAME_ADDRESS
Those are informational values shown to users in settings; DNS and TLS are still your responsibility.
First Run
On a fresh database, Kutt redirects the homepage to:
/create-admin
That is expected. Create the first admin account there.
The health endpoint is:
/api/health
During testing, it returned:
OK
Field Test
I smoke-tested the fixed Home-Lab compose with disposable volumes.
Command shape:
KUTT_PORT=8788 \
DEFAULT_DOMAIN=localhost:8788 \
POSTGRES_PASSWORD=test-postgres-password \
JWT_SECRET=12345678901234567890123456789012 \
docker compose -f /home/jalcocert/Desktop/Home-Lab/kutt/docker-compose.yml -p kutt_smoke up -d
Observed:
- Postgres 16 became healthy.
- Redis 7 started with append-only persistence.
- Kutt ran 10 migrations.
- Kutt started successfully.
GET /api/healthreturned HTTP200withOK.GET /returned HTTP302to/create-admin.GET /create-adminreturned HTTP200.
Then I removed the smoke-test containers and volumes:
docker compose -p kutt_smoke down -v --remove-orphans
Kutt vs Snapp vs Dub
Kutt is the practical middle option.
Compared with Snapp:
- Kutt is older and more established.
- Kutt has first-class custom domain and API docs.
- Snapp currently feels more modern in UI architecture and uses Better-Auth organizations.
- Both are reasonable home-lab candidates.
Compared with Dub:
- Kutt is much lighter.
- Dub is a SaaS-grade link attribution platform with specialized analytics infrastructure.
- Kutt is closer to a traditional self-hosted app: web service, database, optional Redis.
See also:
FAQ
Does Kutt require Postgres?
No. Kutt defaults to SQLite and also supports Postgres and MySQL/MariaDB. For a durable self-hosted deployment, I prefer Postgres, but SQLite is valid for a smaller setup.
Does Kutt require Redis?
No. Redis is optional, but useful for cache, queues, and rate-limit related paths.
What deployment combinations are supported?
Common combinations:
SQLite only
SQLite + Redis
Postgres + Redis
MariaDB/MySQL + Redis
Upstream ships Compose examples for each of those broad paths. The Home-Lab snippet uses Postgres + Redis because it is a good production-like default, not because the lighter modes are invalid.
What is the simplest setup?
SQLite only.
Use a persistent volume for the SQLite database and set:
DB_CLIENT=better-sqlite3
DB_FILENAME=/var/lib/kutt/data.sqlite
You can omit Postgres and Redis entirely.
When should I add Redis?
Add Redis if you want Kutt to use Redis-backed cache/queue behavior and Redis-backed rate limiting. If you run Redis but forget:
REDIS_ENABLED=true
Kutt will ignore Redis.
When should I use Postgres instead of SQLite?
Use Postgres when you want a more standard server database for backups, external administration, multi-service monitoring, and longer-lived deployments. SQLite is simpler; Postgres is more operationally familiar for always-on self-hosted services.
Why set DB_CLIENT=pg?
Without it, Kutt defaults to SQLite. If your Compose starts Postgres but omits DB_CLIENT=pg, Postgres may be running unused.
Why set REDIS_ENABLED=true?
Without it, Kutt will not use Redis even if a Redis container exists.
Can I access it from my LAN?
Yes. Publish the port and set DEFAULT_DOMAIN to the LAN hostname you will use, for example:
DEFAULT_DOMAIN=192.168.1.2:8788
Is registration open by default?
No. The provided snippet keeps registration and anonymous links disabled by default. Create the initial admin through /create-admin.
Verdict
Kutt is a strong self-hosted URL shortener when you want a mature app with custom domains, statistics, API access, and normal Docker deployment.
It is much lighter than Dub, more established than many small URL shorteners, and straightforward to run with Postgres and Redis once the required environment variables are explicit.
Comments