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
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/health returned HTTP 200 with OK.
  • GET / returned HTTP 302 to /create-admin.
  • GET /create-admin returned HTTP 200.

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.