Zum Hauptinhalt springen
S-EDV news
← Alle Anleitungen
📘 Anleitung Docker 29.09.2026 · 11 min Lesezeit Geprüft 05.10.2026

Kutt mit Docker Compose: eigener URL-Shortener mit API, Statistik und Backup

Kutt ist ein schlanker, selbst gehosteter URL-Shortener mit SQLite, Klickstatistik und REST-API. Die Anleitung zeigt Docker Compose mit v3.2.6, Admin, API-Key, gesperrte Registrierung, Backup und Restore, Update und typische Fehler.

Werbung Mit * markierte Links sind Werbelinks: Bei einem Kauf erhalten wir eine Provision, der Preis bleibt gleich. Als Amazon-Partner verdiene ich an qualifizierten Verkäufen. Mehr dazu

Titelbild: Kutt mit Docker Compose, Kurzlinks, Statistik, API

Kurzlinks für Newsletter, Drucksachen, QR-Codes oder Support-Anleitungen liegen oft bei Cloud-Diensten, die jeden Klick mitschneiden. Kutt ist ein quelloffener URL-Shortener (MIT) mit eigenen Kurzlinks, Klickstatistik, REST-API und Admin-Oberfläche, als einzelner Docker-Container mit SQLite.

Vorab: Die offiziellen Compose-Dateien im Repository bauen das Image aus dem Quellcode. Für Server ist das fertige Image kutt/kutt von Docker Hub besser.

Was Kutt ist und wo die Grenzen liegen

Kutt stammt von thedevs-network, Version 3.2.6 erschien am 05.07.2026. Seit Version 3 braucht Kutt keinen Datenbankserver (Standard SQLite, optional PostgreSQL, MySQL, MariaDB, Redis als Cache).

Das bringt Kutt mit:

  • Kurzlinks mit zufälliger oder eigener Adresse, optional Passwort, Ablaufzeit, Beschreibung
  • Klickstatistik pro Link (Zeitraum, Browser, Betriebssystem, Land, Referrer)
  • REST-API unter /api/v2 mit X-API-Key
  • Admin-Seite für Nutzer, Links und Domains
  • Eigene Domains und OIDC-Anmeldung
  • Anpassung über /kutt/custom

Grenzen: Selbstregistrierung, Passwort-Reset und E-Mail-Änderung brauchen SMTP (MAIL_ENABLED=true), sonst legt der Admin alle Konten an. TLS für eigene Link-Domains erzeugt der Reverse Proxy. Für mehrere API-Keys oder Tags passt Shlink besser (Anleitung unten), Kutt hat die Oberfläche für Endnutzer eingebaut.

Voraussetzungen und Ressourcen

  • Linux-Server mit Docker Engine und Compose v2, 1 CPU-Kern
  • amd64 oder arm64 (beide für kutt/kutt:v3.2.6 veröffentlicht)
  • Rund 110 MB Platz für das Image, wenige hundert KB für die leere Datenbank
  • RAM: 107 MiB nach dem Start, 157 MiB nach Nutzung aller Funktionen
  • Produktiv: eigene Subdomain (go.firma.de) und Reverse Proxy mit TLS
  • Optional SMTP für Selbstregistrierung und Passwort-Reset

Das Image enthält Kutt 3.2.6 (Node.js 22 auf Alpine). Version prüfen:

# Version im laufenden Container anzeigen
$ docker exec kutt grep '"version"' package.json

Offizielle Compose-Dateien und warum diese Anleitung abweicht

Alle Compose-Dateien im Repository (docker-compose.yml, .sqlite-redis, .postgres, .mariadb) nutzen build: context: . statt image:, veröffentlichen Port 3000 auf allen Schnittstellen und nutzen benannte Volumes.

Für Server ist ein festes, vorgebautes Image besser (reproduzierbar, klarer Rollback-Punkt). Die Datei übernimmt die SQLite-Variante, ersetzt build durch image, bindet den Port an localhost und nutzt einen Bind-Mount, damit das Backup eine gewöhnliche Datei ist.

Schritt 1: Vollständige compose.yaml und .env

Legen Sie /opt/kutt mit Unterordnern an:

# 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:

# Pflicht: Secret für Login-Tokens
JWT_SECRET=HIER_64_ZEICHEN_AUS_OPENSSL_EINTRAGEN
# Name in der Oberfläche
SITE_NAME=Firmen-Kurzlinks
# Domain der Kurzlinks (ohne https://)
DEFAULT_DOMAIN=go.firma.de
# true nur hinter einem Reverse Proxy, sonst false
TRUST_PROXY=true
# Registrierung 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:

ParameterWirkung
image: kutt/kutt:v3.2.6Feste Version statt latest.
DB_FILENAMESQLite-Datei im gemounteten Ordner ./data.
./custom:/kutt/customEigene CSS-Dateien, Bilder, Vorlagen (optional).
127.0.0.1:3000:3000Nur lokal erreichbar, öffentlich antwortet der Reverse Proxy.
JWT_SECRETPflicht, sonst Missing environment variables: JWT_SECRET. Laut README auch JWT_SECRET_FILE (Docker-Secrets).
DEFAULT_DOMAINDomain für die Kurzlinks im Feld link der API-Antwort.
TRUST_PROXYClient-IP aus Proxy-Headern. Ohne Proxy false, sonst können Clients ihre IP fälschen.
DISALLOW_REGISTRATIONSperrt die Selbstregistrierung (Standard true).
DISALLOW_ANONYMOUS_LINKSVerhindert Kurzlinks ohne Anmeldung (Standard true).

docker compose config -q gibt nichts aus, ls -l /opt/kutt/.env zeigt -rw-------.

Schritt 2: Installation und Start

$ cd /opt/kutt
$ docker compose config -q
$ docker compose up -d
# Log verfolgen
$ docker compose logs -f kutt

Beim Start führt der Container Migrationen aus und startet den Server, das sieht 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 vergehen 8 bis 24 Sekunden, davor liefert curl 000 (kein Fehler).

Das Log endet mit Ready on http://localhost:3000, docker compose ps zeigt den Port nur auf 127.0.0.1:3000.

Schritt 3: Funktionsprüfung und Healthcheck

Der Health-Endpunkt gibt OK zurück, geeignet für Monitoring (etwa Uptime Kuma). Ohne Konto zeigt die Startseite das Formular zum Anlegen des ersten Admins (create-admin im HTML).

# Health-Endpunkt abfragen, erwartet: OK 200
$ curl -s -w ' %{http_code}\n' http://127.0.0.1:3000/api/v2/health

/api/v2/health liefert OK 200.

Schritt 4: Ersten Admin anlegen

Beim ersten Aufruf fordert Kutt zum Anlegen eines Admins auf (Browser per SSH-Tunnel ssh -L 3000:127.0.0.1:3000 server oder 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"}'

Antwort: HTTP 201 mit Token. Ein zweiter Aufruf scheitert, sobald ein Konto existiert (Can not create the admin user because a user already exists., HTTP 400). Legen Sie den Admin sofort an, sonst kann ihn jeder beanspruchen, der Port 3000 erreicht.

Der erste Aufruf liefert HTTP 201 mit Token, ein zweiter HTTP 400 mit Can not create the admin user.

Den API-Key finden Sie in der Oberfläche unter Einstellungen, headless mit dem Login-Token als Cookie token:

# Anmelden
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 (ein Key pro Nutzer). Links legen Sie so 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"}'

Antwort: HTTP 201 mit "address":"sedv", "visit_count":0 und "link":"https://go.firma.de/sedv". Ohne customurl vergibt Kutt eine sechsstellige Zufallsadresse (LINK_LENGTH).

Der POST auf /api/v2/links liefert HTTP 201 mit "address":"sedv".

# 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

Antwort: HTTP 302 Location: https://s-edv.com/, unbekannte Kurzlinks leiten per 302 auf /404. Kutt filtert Bots per User-Agent: Der curl-Standard leitet weiter, zählt aber nicht als Klick, erst eine Browser-Kennung erhöht den Zähler:

# Klick mit Browser-Kennung
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
curl -s http://127.0.0.1:3000/api/v2/links/LINK-ID/stats -H "X-API-Key: $KUTT_KEY"

Klicks laufen über eine Warteschlange, der Zähler läuft ein bis zwei Sekunden nach. Die Statistik liefert lastDay, lastWeek, lastMonth, lastYear mit Browser, Betriebssystem, Land und Referrer (Beispiel: visit_count: 1, firefox 1, linux 1).

Der Aufruf von /sedv liefert HTTP 302 auf das Ziel, und nach einem Klick mit Browser-Kennung steht visit_count auf 1.

Schritt 7: Registrierung sperren und Zugriffe absichern

Mit DISALLOW_REGISTRATION=true antwortet POST /api/v2/auth/signup mit {"error":"Request is not allowed."} (HTTP 400), /signup mit 404. Auch mit false bleibt die Registrierung gesperrt, solange MAIL_ENABLED=false gilt (Konten werden per E-Mail bestätigt). Offene Registrierung braucht beides.

Weitere Nutzer legt der Admin in der Oberfläche an, per API listet GET /api/v2/users/admin Konten mit Rolle und Linkanzahl. Fehlende oder falsche Keys 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'

Ein anonymer POST auf /api/v2/links ergibt 401, Kurzlinks auf die eigene Domain lehnt Kutt ab (URLs are not allowed, HTTP 400).

POST /api/v2/auth/signup liefert HTTP 400 Request is not allowed., ein falscher Key HTTP 401.

Persistente Daten und Rechte

Der gesamte Zustand steckt in ./data/data.sqlite (Nutzer, Passwort-Hashes, API-Keys, Links, Besuche, Domains), ./custom nur in Anpassungen. Der Container läuft als root, die Datenbank gehört root: Beim Restore scheitert ein normaler Benutzer mit cp: cannot create regular file 'data/data.sqlite': Permission denied, Aufräumen braucht sudo. Es gibt keine WAL-Dateien (Journal-Modus delete).

JWT_SECRET steht nur in der .env. Sichern Sie sie getrennt und verschlüsselt mit. Bei Verlust werden nur Browser-Sitzungen ungültig, API-Keys liegen in der Datenbank.

sudo ls -l /opt/kutt/data zeigt data.sqlite, und nach docker compose restart kutt sind Links und API-Key noch gültig.

Schritt 8: Netzwerkfreigabe, Reverse Proxy und TLS

Kutt terminiert kein TLS: Ein Reverse Proxy holt das Zertifikat für go.firma.de und leitet auf 127.0.0.1:3000. Mit Caddy genügt:

# /etc/caddy/Caddyfile
go.firma.de {
    reverse_proxy 127.0.0.1:3000
}

Laut README gehört dann TRUST_PROXY=true in die .env (Client-IP aus Proxy-Headern), ohne Proxy false. DEFAULT_DOMAIN muss der öffentlichen Domain entsprechen. Für zusätzliche Domains: DNS-Eintrag auf den Server, Domain in den Einstellungen eintragen, Zertifikat selbst besorgen, CUSTOM_DOMAIN_USE_HTTPS=true für https-Links.

curl -sI https://go.firma.de/sedv liefert HTTP 302 mit gültigem Zertifikat, von außen ist Port 3000 nicht erreichbar.

Schritt 9: Backup und Restore

Am sichersten ist ein kaltes Backup (Container kurz stoppen, Datei kopieren, starten), Kurzlinks sind dabei wenige Sekunden nicht erreichbar:

# Kaltes Backup
$ 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 des Moduls better-sqlite3, das Ergebnis lässt sich auf Integrität prüfen:

# Online-Backup
$ 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 verschieben
$ sudo mv /opt/kutt/data/backup-online.sqlite /backup/kutt-online-$(date +%F).sqlite

Beispiel für den Restore: Link sedv per DELETE /api/v2/links/LINK-ID gelöscht (/sedv leitet dann auf /404); nach dem Zurückspielen der Datenbank /sedv leitet wieder per 302, visit_count 1 und API-Key sind erhalten. Spielen Sie die root-eigene Datei per docker compose cp in den gestoppten Container zurück:

# 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

Die Integritätsprüfung meldet ok, und nach dem Restore leitet /sedv wieder per 302 auf das Ziel.

Schritt 10: Updates und Rollback-Grenzen

Update: Backup, neuer Tag in der compose.yaml, Neustart. Migrationen laufen beim Start mit knex migrate:latest.

$ 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

Rollback: Das Projekt dokumentiert keinen Downgrade-Weg. Nehmen Sie das alte Image plus Sicherung von vor dem Update, nie ein altes Image auf einer migrierten Datenbank (neuere Links gehen verloren). main auf Docker Hub ist Entwicklungsstand.

docker exec kutt grep '"version"' package.json zeigt die neue Version, das Log enthält keine Migrationsfehler.

Typische Fehler mit Diagnose und Lösung

SymptomUrsacheLösung
Container beendet sich sofort, Log zeigt Missing environment variables: JWT_SECRETSecret fehlt oder ist leerWert mit openssl rand -hex 32 in .env eintragen
curl meldet Code 000 direkt nach dem StartMigrationen laufen nochAuf Ready on http://localhost:3000 im Log warten
{"error":"Unauthorized."} mit HTTP 401Key falsch, neu erzeugt oder Header fehltHeader X-API-Key prüfen, Skripte anpassen
Klickzähler bleibt bei 0Bot-User-Agents zählen nichtMit Browser oder curl -A testen
Custom URL is already in use. mit HTTP 500Adresse existiert bereitsAndere customurl oder bestehenden Link per PATCH ändern
Request is not allowed. bei der RegistrierungDISALLOW_REGISTRATION=true oder MAIL_ENABLED=falseKonten als Admin anlegen oder SMTP einrichten
Permission denied beim Zurückkopieren der DatenbankDatei gehört root (Container läuft als root)docker compose cp oder sudo cp verwenden
Falsche Client-IPs in der StatistikTRUST_PROXY passt nicht zum AufbauMit Proxy true, ohne Proxy false

Saubere Deinstallation

Achtung: Die Schritte löschen Kurzlinks, Statistiken, Konten und API-Keys endgültig, verteilte Kurzlinks (etwa in QR-Codes) funktionieren danach nicht mehr. Sichern Sie vorher.

# Container und Netzwerk entfernen
$ cd /opt/kutt
$ docker compose down --remove-orphans
# Image entfernen
$ docker rmi kutt/kutt:v3.2.6
# Daten löschen (unwiderruflich)
$ sudo rm -rf /opt/kutt

Entfernen Sie auch Proxy-Block und DNS-Eintrag der Kurzlink-Domain.

Häufige Fragen

Braucht Kutt einen Datenbankserver?

Nein. Seit Version 3 nutzt Kutt standardmäßig SQLite. Optional sind PostgreSQL, MySQL, MariaDB und Redis als Cache möglich.

Wo liegen die Daten, und was gehört ins Backup?

Der gesamte Zustand steckt in ./data/data.sqlite, ./custom enthält nur Anpassungen. Den JWT_SECRET aus der .env sichern Sie getrennt und verschlüsselt mit.

Wie sperre ich die Selbstregistrierung?

Mit DISALLOW_REGISTRATION=true. Auch mit false bleibt die Registrierung gesperrt, solange MAIL_ENABLED=false gilt. Offene Registrierung braucht beides.

Warum meldet curl direkt nach dem Start den Code 000?

Die Migrationen laufen noch. Warten Sie im Log auf Ready on http://localhost:3000, das dauert 8 bis 24 Sekunden. Das ist kein Fehler.

Gibt es einen Weg zurück auf eine ältere Version?

Das Projekt dokumentiert keinen Downgrade. Nehmen Sie das alte Image plus die Sicherung von vor dem Update, nie ein altes Image auf einer migrierten Datenbank, sonst gehen neuere Links verloren.

Fazit

Kutt läuft ohne Datenbankserver: Eine SQLite-Datei im gemounteten Ordner trägt den gesamten Zustand. Mit fester Image-Version, lokal gebundenem Port und einem Reverse Proxy für TLS ist der Betrieb übersichtlich. Sperren Sie die Registrierung, sichern Sie JWT_SECRET und Datenbank, und üben Sie den Restore einmal. Updates spielen Sie nur mit frischem Backup ein, weil der Rückweg über die Sicherung führt.

Weiterführende Anleitungen und Quellen

Passende Anleitungen auf S-EDV

Quellen

KuttURL-ShortenerDocker ComposeSQLiteSelfhostingREST-APIBackup