Kutt runs closed by default: one admin, SQLite, and a domain you must set
Free Modern URL Shortener.
At a glance
- What is it?
- Kutt is a self-hosted URL shortener written in Node.js, with custom domains, per-link passwords and expiry, private statistics, an admin page and a RESTful API. Its defaults are deliberately locked down, registration and anonymous links are both off until you change them, and a fresh container will mint short links pointing at localhost unless you set DEFAULT_DOMAIN.
- Who is it for?
- Kutt fits a team that wants its own link domain, its own statistics and its own retention rules, and that can accept Node.js 20 or above plus a single Node process. It does not fit anyone who needs signup working on day one, because registration rides on mail delivery.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 27 days ago.
- What is it written in?
- Mainly JavaScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 14, 2026, and from our analysis. They are not legal advice.
Star history
- thedevs-network/kutt11,206 ★ · +89
| Repository | 2026-09-22 | 2026-11-14 |
|---|---|---|
| thedevs-network/kutt | 11.1k | 11.2k |
89 Stars in the last 53 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
A fresh Kutt install admits one admin and nobody else
Three switches decide who can put a link into the system, and all three sit in their restrictive position out of the box. `DISALLOW_REGISTRATION` defaults to `true`, so no self-service signup. `DISALLOW_ANONYMOUS_LINKS` also defaults to `true`, so a visitor with no account cannot shorten anything. `DISALLOW_LOGIN_FORM` defaults to `false`, meaning the ordinary email and password form is still there for the accounts you create by hand. On first start you are prompted to create an admin account, and until you change those two flags that single account is the entire user base.
The registration flag has a trap worth knowing before you flip it. Its own note states that if `MAIL_ENABLED` is set to false, registration would still be disabled, since it relies on emails to sign up users. So setting `DISALLOW_REGISTRATION=false` on an instance with no mail transport configured does not reopen signup, it just produces a second reason the form is dead. `nodemailer` 9.0.3 is in the dependency list, so the plumbing is there. The README points at `.example.env` for the full list of settings.
npm run migrate is a separate step, and nothing rolls it back
The prerequisite is Node.js version 20 or above, and the default database is SQLite. In the order the setup section gives them:
npm install
npm run migrate
npm run dev`npm start` is the production alternative to `npm run dev`. The scripts block in package.json shows what each one actually invokes:
"scripts": {
"dev": "node --watch-path=./server --watch-path=./custom server/server.js",
"start": "node server/server.js --production",
"migrate": "knex migrate:latest",
"migrate:make": "knex migrate:make",
"docs:build": "cd docs/api && node generate && cd ../.."
},Note what is absent. `start` runs the server and nothing else, so a bare `npm start` on a fresh clone leaves you pointing at a schema that was never created. The Dockerfile makes the correct order explicit by running migration and start together as its command. There is also no rollback script in that block, only `migrate` and `migrate:make`.
Two smaller details. The dev script watches `./server` and `./custom`, so a file dropped into the customisation directory restarts the process on its own. And package.json declares version 3.2.6, matching the newest release tag v3.2.6 published on 2026-07-05, with the last push to the branch on 2026-10-19.
JWT_SECRET is the one variable you cannot skip, and TRUST_PROXY is the one that bites
Every environment variable is optional except `JWT_SECRET`, which is required on production. It signs authentication tokens, and the configuration table's advice for it is a long random string. Behind that single variable sits `jsonwebtoken` 9.0.2, `passport-jwt` 4.0.1 and `cookie-session` 2.1.1, which means one secret governs every session in the instance. Changing `JWT_SECRET` would invalidate sessions signed with the old value.
Secrets can also come from a file. Appending `_FILE` to a variable name redirects it to a path, for example `JWT_SECRET_FILE=/path/to/secret_file`. That is the clean way to use a Docker secret, but the bundled compose file has no secrets section, so wiring it up is your work rather than the project's.
`TRUST_PROXY` is the setting with security consequences, and it defaults to `true`. Its note says that if you are not using a proxy server then set this to false, otherwise users can override their IP address. Read plainly: on a default deployment with no reverse proxy in front of it, a forwarded-for header arriving from any client is believed. Since private per-link statistics are a listed feature, the visitor IP in your stats is then whatever the requester chose to send. Set it to `false` when nothing proxies the app, and audit it whenever your topology changes.
The image is node:22-alpine while the setup section asks for Node 20 or above
Docker is the path with the least ceremony. With Docker installed, from the root directory:
docker compose upAn official image is also published to Docker Hub as `kutt/kutt`. The image definition pins the runtime and the mode explicitly:
FROM node:22-alpine
# use production node environment by default
ENV NODE_ENV=productionSo the documented floor of Node.js 20 or above and the shipped runtime of Node 22 are two different numbers, and the gap between them is where a hand-rolled install can drift from the containerised one. The build also runs `npm ci --omit=dev`, creates `/var/lib/kutt`, copies the tree, exposes 3000, and runs migration before start.
The trap in this path is `DEFAULT_DOMAIN`, which defaults to `localhost:3000` and is described as the domain address the app runs on. Every short address Kutt generates is built from that value, and the compose file maps port 3000 to the host without overriding it. Start the container, leave the variable alone, and your instance will hand out links pointing at your own loopback interface. `PORT` defaults to `3000` too, so changing one without the other leaves the generated domain inconsistent with where the app is actually listening.
Four compose files, four different required variable sets
The variants are chosen with an explicit file argument:
docker compose -f <file_name> up`docker-compose.yml` is the default and needs nothing beyond what it sets itself. `docker-compose.sqlite-redis.yml` adds Redis and requires `REDIS_ENABLED`. `docker-compose.postgres.yml` runs Postgres with Redis and requires `REDIS_ENABLED`, `DB_PASSWORD`, `DB_NAME` and `DB_USER`. `docker-compose.mariadb.yml` runs MariaDB with Redis and requires those four plus `DB_PORT`. That last one is not arbitrary: `DB_PORT` defaults to `5432`, the Postgres port, so a MariaDB deployment that omits it gets a client pointed at the wrong port.
The default file is short enough to read in full, and it explains where state lives:
services:
server:
build:
context: .
volumes:
- db_data_sqlite:/var/lib/kutt
- custom:/kutt/custom
environment:
DB_FILENAME: "/var/lib/kutt/data.sqlite"
ports:
- 3000:3000
volumes:
db_data_sqlite:
custom:Two consequences follow. `DB_FILENAME` defaults to `db/data`, a relative path suited to a local checkout, and the compose file overrides it to an absolute path inside a named volume. And both the database file and your `custom/` overrides sit in named volumes rather than in the working tree, so anything that discards those volumes takes your links and your themes with it.
The database client you pick may not be the one that got installed
`DB_CLIENT` accepts `pg` or `pg-native` for Postgres, `mysql2` for MySQL or MariaDB, and `sqlite3` or `better-sqlite3` for SQLite, with `better-sqlite3` as the default. Its note states that `pg-native` and `sqlite3` are not installed by default and must be added with npm before use. That is a small line with a large tail. Choosing `pg-native` or `sqlite3` without installing it first produces a module that cannot be resolved, and because the image runs `npm ci --omit=dev` from the committed lockfile, whatever you install locally is not in the container either.
Moving from SQLite to Postgres changes more than the driver. `DB_POOL_MIN` defaults to `0` and `DB_POOL_MAX` to `10`, and both apply only to Postgres or MySQL, so connection behaviour and a hard ceiling of ten pooled connections appear at the moment you switch. `DB_SSL` defaults to `false`, meaning an off-host database gets an unencrypted connection unless you say otherwise.
Redis is the other switch with a scaling consequence. `REDIS_ENABLED` defaults to `false`, `REDIS_DB` accepts a number between 0 and 15, and the stack includes `ioredis` 5.4.2, `bull` 4.16.5 and `rate-limit-redis` 4.2.0 alongside `express-rate-limit` 7.5.0. Per the README, Redis is only used for cache when REDIS_ENABLED is true.
kutt.it belongs to someone else now, and DEFAULT_DOMAIN is why that matters
The warning at the top of the README states that kutt.it is not owned by the project, that it could be a phishing site, that the domain was deactivated by the Italian TLD registrar for lack of identification documents, and that it is now owned by someone else. It directs readers to kutt.to and says that all previous and future links work with that domain.
Mechanically this is not a special case. Every link Kutt mints is assembled from `DEFAULT_DOMAIN`, described simply as the domain address the app runs on, which is why the same variable is what produces `localhost:3000` links in an unconfigured container. Self-hosted instances set it to a host they control, and custom domain support lets you bind a domain of your own so shared-brand questions do not arise at all. LINK_CUSTOM_ALPHABET and `LINK_LENGTH`, which defaults to `6`, shape the short half; the default alphabet deliberately omits o, O, 0, i, I, l, 1 and j so that a copied address is not misread, and you can substitute your own set of characters.
For a reader evaluating this, the practical consequence is narrow but firm. Do not enter credentials at kutt.it, and treat any page there as hostile.
Editorial conclusion
Kutt fits a team that wants its own link domain, its own statistics and its own retention rules, and that can accept Node.js 20 or above plus a single Node process. It does not fit anyone who needs signup working on day one, because registration rides on mail delivery. Before deploying, set DEFAULT_DOMAIN to the real host, flip DISALLOW_REGISTRATION and DISALLOW_ANONYMOUS_LINKS only after MAIL_ENABLED works, decide whether TRUST_PROXY is honest for your topology, and run npm run migrate yourself because npm start does not.
Frequently asked questions
What is kutt it?
It was the project's former domain. The README warns that kutt.it is no longer owned by them, that the Italian TLD registrar deactivated it for lack of identification documents, and that it may be a phishing site. It directs readers to kutt.to and says previous and future links work with that domain.
How do I run Kutt with Docker?
Run `docker compose up` from the root directory, or select a variant with `docker compose -f <file_name> up`. The default file uses SQLite with no extra variables; the sqlite-redis, postgres and mariadb files each require REDIS_ENABLED plus their own database variables. An official image is published as `kutt/kutt` on Docker Hub.
What does Kutt need before it will start?
Node.js version 20 or above, with SQLite as the default database; Postgres or MySQL/MariaDB and Redis are optional. Run `npm install`, then `npm run migrate`, then `npm run dev` or `npm start`, because the start script does not migrate the schema itself.
Why can I not register users on a fresh Kutt install?
DISALLOW_REGISTRATION and DISALLOW_ANONYMOUS_LINKS both default to true. The note on the registration flag adds that if MAIL_ENABLED is false, registration would still be disabled, because signup relies on emails to create accounts.
Which database clients can Kutt use?
DB_CLIENT accepts pg or pg-native for Postgres, mysql2 for MySQL or MariaDB, and sqlite3 or better-sqlite3 for SQLite, with better-sqlite3 as the default. pg-native and sqlite3 are not installed by default and have to be added with npm before use.
Does Kutt need any configuration to run?
All environment variables are optional except JWT_SECRET, which is required on production. Values can be passed directly, placed in a .env file, or read from a path by appending _FILE to the name, for example JWT_SECRET_FILE=/path/to/secret_file.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/thedevs-network-kutt)Star history chart for your README
[](https://hysenlabs.com/projects/thedevs-network-kutt)Was this useful?