Kutt mit Docker Compose: eigener URL-Shortener mit API, Statistik und Backup
Kutt ist ein schlanker, quelloffener URL-Shortener mit Weboberfläche, REST-API und Klickstatistik. Diese Anleitung richtet Kutt 3.2.6 mit Docker Compose und SQLite ein, legt Admin und API-Key an, prüft Redirects und Statistik und zeigt ein getestetes Backup und Restore der Datenbank.

Wer im Unternehmen Links für Newsletter, Drucksachen, QR-Codes oder Support-Anleitungen verkürzen will, landet schnell bei Cloud-Diensten, die jeden Klick mitschneiden und Daten außerhalb der eigenen Infrastruktur speichern. Kutt ist ein quelloffener URL-Shortener unter MIT-Lizenz, der eigene Kurzlinks, Klickstatistiken, eine REST-API und eine Admin-Oberfläche mitbringt und als einzelner Docker-Container mit SQLite läuft.
Diese Anleitung zeigt den kompletten Weg: compose.yaml mit festgelegtem Image-Tag, .env mit Pflicht-Secret, erster Admin, API-Key, Kurzlinks per API, Redirect- und Statistikprüfung, gesperrte Selbstregistrierung, Backup und Restore der SQLite-Datenbank sowie Update und Deinstallation. Der wichtigste Praxisfund vorweg: Die offiziellen Compose-Dateien im Repository bauen das Image aus dem Quellcode. Für den Betrieb auf einem Server ist das fertige Image kutt/kutt von Docker Hub der bessere Weg.
Was Kutt ist und wo die Grenzen liegen
Kutt stammt vom Projekt thedevs-network auf GitHub. Laut GitHub-API (Abruf 29.09.2026) hat das Repository 11.128 Sterne, ist nicht archiviert, der letzte Push war am 03.09.2026, und das aktuelle Release ist v3.2.6 vom 05.07.2026. Seit Version 3 läuft Kutt ohne Pflicht-Datenbankserver: Standard ist SQLite, optional werden PostgreSQL, MySQL oder MariaDB sowie Redis als Cache unterstützt.
Das bringt Kutt mit:
- Kurzlinks mit zufälliger oder eigener Adresse, optional mit Passwort, Ablaufzeit und Beschreibung
- Klickstatistik pro Link nach Zeitraum, Browser, Betriebssystem, Land und Referrer
- REST-API unter
/api/v2mit Authentifizierung perX-API-Key - Admin-Seite zur Verwaltung von Nutzern, Links und Domains
- Eigene Domains für Kurzlinks sowie Anmeldung per OIDC
- Anpassbares Aussehen über den Ordner
/kutt/custom
Die Grenzen sollten Sie vorher kennen. Selbstregistrierung, Passwort-Reset und E-Mail-Änderung funktionieren nur mit konfiguriertem SMTP-Versand (MAIL_ENABLED=true). Ohne Mail legt der Admin alle Konten selbst an. TLS-Zertifikate für zusätzliche eigene Link-Domains erzeugt Kutt nicht selbst, das übernimmt ein Reverse Proxy. Und Kutt ist bewusst schlank: Wer mehrere API-Keys oder Tags an Links braucht, ist mit Shlink besser bedient. Eine entsprechende Anleitung mit MariaDB finden Sie weiter unten bei den passenden Anleitungen. Kutt punktet dagegen mit eingebauter Weboberfläche für Endnutzer, die bei Shlink als separater Client dazukommt.
Voraussetzungen und Ressourcen
- Linux-Server mit Docker Engine und Docker Compose v2 (
docker compose) - Architektur amd64 oder arm64, beide sind für
kutt/kutt:v3.2.6auf Docker Hub veröffentlicht - Rund 110 MB Speicherplatz für das Image, dazu wenige hundert KB für die leere Datenbank
- Arbeitsspeicher: Im Test belegte der Container 107 MiB direkt nach dem Start und 157 MiB nach allen Tests
- Für den Produktivbetrieb eine eigene Subdomain, etwa
go.firma.de, und ein Reverse Proxy mit TLS - Optional ein SMTP-Konto, falls Nutzer sich selbst registrieren oder Passwörter zurücksetzen sollen
Die getestete Version ist Kutt 3.2.6 (Image kutt/kutt:v3.2.6, Node.js 22 auf Alpine). Die Versionsnummer lässt sich im Container prüfen:
# Version im laufenden Container anzeigen
docker exec kutt grep '"version"' package.json
Offizielle Compose-Dateien und warum diese Anleitung abweicht
Im Repository liegen im Wurzelverzeichnis docker-compose.yml (SQLite), docker-compose.sqlite-redis.yml, docker-compose.postgres.yml und docker-compose.mariadb.yml. Alle enthalten beim Dienst server die Zeile build: context: . und kein image:. Sie sind also dafür gedacht, nach einem git clone lokal zu bauen. Außerdem veröffentlichen sie Port 3000 auf allen Schnittstellen und nutzen benannte Volumes.
Für einen Server ist ein festgelegtes, vorgebautes Image sinnvoller: kein Build-Werkzeug, reproduzierbare Version und ein klarer Rollback-Punkt. Die folgende Datei übernimmt deshalb Pfade und Variablen aus der offiziellen SQLite-Variante, ersetzt aber build durch image, bindet den Port nur an localhost und legt die Daten als Bind-Mount ab, damit das Backup eine gewöhnliche Datei ist.
Vollständige compose.yaml und .env
Legen Sie ein Verzeichnis an, etwa /opt/kutt, und darin die Unterordner für Daten und Anpassungen:
# Projektverzeichnis und Datenordner anlegen
sudo mkdir -p /opt/kutt/data /opt/kutt/custom
cd /opt/kutt
Die compose.yaml:
services:
kutt:
image: kutt/kutt:v3.2.6
container_name: kutt
restart: unless-stopped
env_file: .env
environment:
DB_FILENAME: /var/lib/kutt/data.sqlite
volumes:
- ./data:/var/lib/kutt
- ./custom:/kutt/custom
ports:
- "127.0.0.1:3000:3000"
Die .env enthält nur Platzhalter. Erzeugen Sie das Secret mit openssl rand -hex 32 und tragen Sie es ein:
# Pflicht: langes zufälliges Secret zum Signieren der Login-Tokens
JWT_SECRET=HIER_64_ZEICHEN_AUS_OPENSSL_EINTRAGEN
# Name in der Oberfläche
SITE_NAME=Firmen-Kurzlinks
# Domain, unter der die Kurzlinks erreichbar sind (ohne https://)
DEFAULT_DOMAIN=go.firma.de
# true nur hinter einem Reverse Proxy, sonst false
TRUST_PROXY=true
# Selbstregistrierung und anonyme Links aus
DISALLOW_REGISTRATION=true
DISALLOW_ANONYMOUS_LINKS=true
# Ohne SMTP bleibt Mail aus
MAIL_ENABLED=false
# Secret erzeugen und .env schützen
openssl rand -hex 32
chmod 600 /opt/kutt/.env
Die wichtigsten Parameter im Überblick:
| Parameter | Wirkung |
|---|---|
image: kutt/kutt:v3.2.6 | Festgelegte Version statt latest, damit Updates bewusst erfolgen. |
DB_FILENAME | Pfad der SQLite-Datei im Container. Sie liegt im gemounteten Ordner ./data. |
./custom:/kutt/custom | Eigene CSS-Dateien, Bilder und Vorlagen. Leer lassen, wenn nichts angepasst wird. |
127.0.0.1:3000:3000 | Kutt ist nur lokal erreichbar, öffentlich antwortet ausschließlich der Reverse Proxy. |
JWT_SECRET | Pflicht. Fehlt es, beendet sich der Container mit Missing environment variables: JWT_SECRET. Laut README ist auch JWT_SECRET_FILE für Docker-Secrets möglich. |
DEFAULT_DOMAIN | Domain, aus der Kutt die Kurzlinks im Feld link der API-Antwort baut. |
TRUST_PROXY | Liest die Client-IP aus Proxy-Headern. Ohne Proxy auf false setzen, sonst können Clients ihre IP fälschen. |
DISALLOW_REGISTRATION | Sperrt die Selbstregistrierung. Voreinstellung ist true. |
DISALLOW_ANONYMOUS_LINKS | Verhindert Kurzlinks ohne Anmeldung. Voreinstellung ist true. |
Installation und Start
# Konfiguration prüfen und starten
cd /opt/kutt
docker compose config -q
docker compose up -d
# Start im Log verfolgen
docker compose logs -f kutt
Beim Start führt der Container zuerst die Datenbankmigrationen aus und startet dann den Server. Im Test sah das so aus:
> kutt@3.2.6 migrate
> knex migrate:latest
Batch 1 run: 10 migrations
> kutt@3.2.6 start
> node server/server.js --production
> Ready on http://localhost:3000
Bis Ready vergingen im Test zwischen 8 und 24 Sekunden. Eine Verbindung vor diesem Zeitpunkt liefert bei curl den Code 000, das ist kein Fehler.
Funktionsprüfung und Healthcheck
Kutt bringt einen eigenen Health-Endpunkt mit, der schlicht OK zurückgibt:
# Health-Endpunkt abfragen, erwartet: OK 200
curl -s -w ' %{http_code}\n' http://127.0.0.1:3000/api/v2/health
Für externes Monitoring, etwa mit Uptime Kuma, eignet sich genau diese URL. Solange noch kein Konto existiert, zeigt die Startseite das Formular zum Anlegen des ersten Admins, erkennbar an create-admin im HTML.
Ersten Admin anlegen
Beim ersten Aufruf der Oberfläche fordert Kutt dazu auf, ein Admin-Konto anzulegen. Das geht im Browser über einen SSH-Tunnel (ssh -L 3000:127.0.0.1:3000 server) oder direkt per API:
# Ersten Admin anlegen (Passwort 8 bis 64 Zeichen)
curl -s -X POST http://127.0.0.1:3000/api/v2/auth/create-admin \
-H 'Content-Type: application/json' \
-d '{"email":"admin@firma.de","password":"LangesPasswort-2026"}'
Die Antwort ist HTTP 201 mit einem Token. Ein zweiter Aufruf wird abgewiesen, sobald irgendein Konto existiert: {"error":"Can not create the admin user because a user already exists."} mit HTTP 400. Legen Sie den Admin trotzdem sofort nach dem ersten Start an, denn bis dahin könnte jeder, der Port 3000 erreicht, dieses Konto für sich beanspruchen. Das ist ein weiterer Grund für die Bindung an localhost.
API-Key erzeugen und Kurzlinks per API anlegen
Den API-Key finden Sie in der Oberfläche unter Einstellungen. Headless klappt es mit dem Login-Token, das Kutt als Cookie token erwartet:
# Anmelden und Token merken
TOKEN=$(curl -s -X POST http://127.0.0.1:3000/api/v2/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@firma.de","password":"LangesPasswort-2026"}' | jq -r .token)
# API-Key erzeugen (40 Zeichen)
curl -s -X POST http://127.0.0.1:3000/api/v2/auth/apikey \
-H 'Accept: application/json' -b "token=$TOKEN"
Jeder Aufruf erzeugt einen neuen Key und macht den alten ungültig, pro Nutzer gibt es genau einen. Mit dem Key legen Sie Links an:
# Kurzlink mit eigener Adresse anlegen
curl -s -X POST http://127.0.0.1:3000/api/v2/links \
-H "X-API-Key: $KUTT_KEY" -H 'Content-Type: application/json' \
-d '{"target":"https://s-edv.com/","customurl":"sedv","description":"Testlink"}'
Die Antwort kommt mit HTTP 201 und enthält unter anderem "address":"sedv", "visit_count":0 und "link":"https://go.firma.de/sedv". Ohne customurl vergibt Kutt eine sechsstellige Zufallsadresse wie vsTD6a. Die Länge steuert LINK_LENGTH.
Redirect und Link-Statistik prüfen
# Redirect prüfen, erwartet: HTTP 302 und Location auf das Ziel
curl -s -o /dev/null -w 'HTTP %{http_code} Location: %{redirect_url}\n' http://127.0.0.1:3000/sedv
Kutt antwortet mit HTTP 302 Location: https://s-edv.com/. Ein unbekannter Kurzlink leitet per 302 auf /404 weiter. Wichtig für eigene Tests: Kutt filtert Bots anhand des User-Agents. Ein Aufruf mit dem Standard-User-Agent von curl leitet zwar weiter, zählt aber nicht als Klick. Erst ein Aufruf mit Browser-Kennung erhöht den Zähler:
# Klick mit Browser-Kennung auslösen
curl -s -o /dev/null -A 'Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0' \
http://127.0.0.1:3000/sedv
# Statistik des Links abrufen
curl -s http://127.0.0.1:3000/api/v2/links/LINK-ID/stats -H "X-API-Key: $KUTT_KEY"
Die Klicks werden über eine interne Warteschlange verbucht, daher kann der Zähler ein bis zwei Sekunden nachlaufen. Die Statistik liefert Blöcke für lastDay, lastWeek, lastMonth und lastYear, jeweils mit Aufschlüsselung nach Browser, Betriebssystem, Land und Referrer. Im Test stand nach einem Klick visit_count: 1 mit firefox 1, linux 1 und referrer direct 1. Das Land bleibt bei lokalen Adressen unknown.
Registrierung sperren und Zugriffe absichern
Mit DISALLOW_REGISTRATION=true ist die Selbstregistrierung aus: POST /api/v2/auth/signup antwortet mit {"error":"Request is not allowed."} und HTTP 400, die Seite /signup mit 404. Bemerkenswert: Auch mit DISALLOW_REGISTRATION=false bleibt die Registrierung gesperrt, solange MAIL_ENABLED=false gesetzt ist, weil Kutt neue Konten per E-Mail bestätigt. Wer Registrierung wirklich öffnen will, braucht also beides.
Weitere Nutzer legen Sie als Admin in der Oberfläche an. Die Admin-Endpunkte sind auch per API erreichbar, etwa GET /api/v2/users/admin, das Konten mit Rolle und Linkanzahl auflistet. Zugriffe ohne oder mit falschem Key werden abgewiesen:
# Falscher Key, erwartet: {"error":"Unauthorized."} HTTP 401
curl -s -w ' HTTP %{http_code}\n' http://127.0.0.1:3000/api/v2/links -H 'X-API-Key: falscher-key-123'
Auch ein anonymer POST auf /api/v2/links ergibt 401. Kurzlinks auf die eigene Domain lehnt Kutt ab (URLs are not allowed, HTTP 400), damit keine Umleitungsschleifen entstehen.
Persistente Daten und Rechte
Der gesamte Zustand steckt in einer Datei: ./data/data.sqlite. Darin liegen Nutzer, gehashte Passwörter, API-Keys, Links, Besuche und Domains. Der Ordner ./custom enthält nur optionale Anpassungen. Der Container läuft als root, deshalb gehört die neu angelegte Datenbank auf dem Host dem Benutzer root. Das hat zwei Folgen: Ein normaler Benutzer kann sie beim Restore nicht einfach überschreiben (cp: cannot create regular file 'data/data.sqlite': Permission denied), und beim Aufräumen braucht es sudo. Die SQLite-Datei läuft im Journal-Modus delete, es gibt also keine zusätzlichen WAL-Dateien, die beim Kopieren vergessen werden könnten.
Wichtig für die Sicherheit: JWT_SECRET steht nur in der .env, nicht in der Datenbank. Sichern Sie die .env deshalb getrennt und verschlüsselt mit. Geht das Secret verloren, werden lediglich bestehende Browser-Sitzungen ungültig, API-Keys liegen in der Datenbank.
Netzwerkfreigabe, Reverse Proxy und TLS
Kutt terminiert selbst kein TLS. Setzen Sie einen Reverse Proxy davor, der für go.firma.de ein Zertifikat bezieht und auf 127.0.0.1:3000 weiterleitet. Mit Caddy genügt ein Block:
# /etc/caddy/Caddyfile
go.firma.de {
reverse_proxy 127.0.0.1:3000
}
Laut README gehört dann TRUST_PROXY=true in die .env, damit Kutt die echte Client-IP aus den Proxy-Headern liest. Läuft Kutt ausnahmsweise ohne Proxy, stellen Sie den Wert auf false. DEFAULT_DOMAIN muss exakt der öffentlichen Domain entsprechen. Für zusätzliche Kundendomains richten Sie laut README einen DNS-Eintrag auf den Server ein, tragen die Domain in den Einstellungen ein und kümmern sich selbst um das Zertifikat. CUSTOM_DOMAIN_USE_HTTPS=true sorgt dann dafür, dass Kutt für diese Domains https-Links erzeugt. Die Werte SERVER_IP_ADDRESS und SERVER_CNAME_ADDRESS dienen nur der Anzeige in den Einstellungen.
Backup und Restore
Die sicherste Variante für kleine Instanzen ist ein kaltes Backup: Container kurz stoppen, Datei kopieren, wieder starten. Kurzlinks sind in diesen wenigen Sekunden nicht erreichbar, planen Sie das in ein ruhiges Zeitfenster.
# Kaltes Backup der SQLite-Datenbank
cd /opt/kutt
docker compose stop kutt
sudo cp -a data/data.sqlite /backup/kutt-$(date +%F).sqlite
docker compose start kutt
sha256sum data/data.sqlite /backup/kutt-$(date +%F).sqlite
Ohne Unterbrechung geht es mit der SQLite-Backup-API, die das mitgelieferte Modul better-sqlite3 bereitstellt. Das Ergebnis lässt sich direkt auf Integrität prüfen:
# Online-Backup im laufenden Container
docker exec kutt node -e "require('better-sqlite3')('/var/lib/kutt/data.sqlite').backup('/var/lib/kutt/backup-online.sqlite').then(p=>console.log('pages',p.totalPages))"
# Integrität prüfen, erwartet: ok
docker exec kutt node -e "console.log(require('better-sqlite3')('/var/lib/kutt/backup-online.sqlite',{readonly:true}).pragma('integrity_check',{simple:true}))"
# Sicherung aus dem Datenordner wegbewegen
sudo mv /opt/kutt/data/backup-online.sqlite /backup/kutt-online-$(date +%F).sqlite
Der Restore wurde so geprüft: Backup erstellt, den Link sedv per DELETE /api/v2/links/LINK-ID gelöscht (danach leitete /sedv auf /404 um), Datenbank zurückgespielt, Container gestartet. Danach leitete /sedv wieder mit 302 auf das Ziel, der Link hatte seinen alten Zähler visit_count 1, und der API-Key war weiterhin gültig. Weil die Datei root gehört, spielen Sie sie am einfachsten per docker compose cp in den gestoppten Container:
# Restore in den gestoppten Container
cd /opt/kutt
docker compose stop kutt
docker compose cp /backup/kutt-2026-09-29.sqlite kutt:/var/lib/kutt/data.sqlite
docker compose start kutt
# Kontrolle: Redirect wieder da
curl -s -o /dev/null -w 'HTTP %{http_code} Location: %{redirect_url}\n' http://127.0.0.1:3000/sedv
Updates und Rollback-Grenzen
Ein Update besteht aus Backup, neuem Tag in der compose.yaml und Neustart. Die Datenbankmigrationen laufen beim Start automatisch mit knex migrate:latest.
# Update auf eine neue Version
cd /opt/kutt
docker compose stop kutt
sudo cp -a data/data.sqlite /backup/kutt-vor-update-$(date +%F).sqlite
# Tag in compose.yaml anpassen, dann:
docker compose pull
docker compose up -d
docker compose logs --tail 20 kutt
Die Grenze beim Rollback: Die Migrationen passen das Datenbankschema beim Start an, einen Downgrade-Weg dokumentiert das Projekt nicht. Ein altes Image auf eine bereits migrierte Datenbank loszulassen, ist daher riskant. Ein sauberer Rollback heißt deshalb immer altes Image plus die Datenbanksicherung von vor dem Update. Links, die nach dem Update angelegt wurden, gehen dabei verloren. Auf Docker Hub gibt es neben latest und den Versions-Tags auch main, das den aktuellen Entwicklungsstand abbildet und für den Betrieb nicht gedacht ist.
Typische Fehler mit Diagnose und Lösung
| Symptom | Ursache | Lösung |
|---|---|---|
Container beendet sich sofort, Log zeigt Missing environment variables: JWT_SECRET | Secret fehlt oder ist leer | Wert mit openssl rand -hex 32 erzeugen und in .env eintragen |
curl meldet Code 000 direkt nach dem Start | Migrationen laufen noch | Auf Ready on http://localhost:3000 im Log warten |
{"error":"Unauthorized."} mit HTTP 401 | Key falsch, neu erzeugt oder Header fehlt | Header X-API-Key prüfen, nach Neuerzeugung alte Skripte anpassen |
| Klickzähler bleibt bei 0 | Aufrufe mit Bot-User-Agent werden nicht gezählt | Mit Browser testen oder curl mit -A und Browser-Kennung |
Custom URL is already in use. mit HTTP 500 | Adresse existiert bereits | Andere customurl wählen oder bestehenden Link per PATCH ändern |
Request is not allowed. bei der Registrierung | DISALLOW_REGISTRATION=true oder MAIL_ENABLED=false | Konten als Admin anlegen oder SMTP konfigurieren |
Permission denied beim Zurückkopieren der Datenbank | Datei gehört root, weil der Container als root läuft | docker compose cp oder sudo cp verwenden |
| Falsche Client-IPs in der Statistik | TRUST_PROXY passt nicht zum Aufbau | Mit Proxy true, ohne Proxy false |
Saubere Deinstallation
Achtung: Die folgenden Schritte löschen alle Kurzlinks, Statistiken, Konten und API-Keys endgültig. Bereits verteilte Kurzlinks, etwa in gedruckten QR-Codes, funktionieren danach nicht mehr. Erstellen Sie vorher ein Backup, falls Sie die Daten später noch brauchen.
# Container und Netzwerk entfernen
cd /opt/kutt
docker compose down --remove-orphans
# Image entfernen
docker rmi kutt/kutt:v3.2.6
# Daten und Konfiguration löschen (unwiderruflich)
sudo rm -rf /opt/kutt
Vergessen Sie nicht, den Block im Reverse Proxy und den DNS-Eintrag für die Kurzlink-Domain zu entfernen.
Testumfang
Am 29.09.2026 wurden mit kutt/kutt:v3.2.6 auf einem Linux-Host (amd64) Start, Health-Endpunkt, Admin-Anlage, API-Key, Kurzlinks per API, 302-Redirect, Klickzählung mit Statistik, 401 bei falschem Key, gesperrte Registrierung, Persistenz nach Neustart sowie Backup (kalt und online) und Restore nach gelöschtem Link praktisch geprüft. Reverse Proxy mit TLS, eigene Domains, SMTP, OIDC und die Varianten mit PostgreSQL, MariaDB oder Redis stammen aus der Projektdokumentation und wurden nicht selbst betrieben.
Passende Anleitungen auf S-EDV
- Shlink selbst hosten mit Docker Compose und MariaDB: die Alternative mit Tags, mehreren API-Keys und separatem Web-Client
- Caddy als Reverse Proxy mit automatischem HTTPS: passend für die öffentliche Kurzlink-Domain
- Docker Compose Grundlagen: Stacks, Volumes und Updates im Überblick


