---
title: "Kutt mit Docker Compose: eigener URL-Shortener mit API, Statistik und Backup"
date: 2026-09-29T02:15:39+02:00
modified: 2026-11-10T17:46:41+01:00
category: docker
type: how_to
author: Marcel Schönfelder
canonical: https://s-edv.com/anleitungen/kutt-url-shortener-docker-compose-api-statistik-backup
keywords:
  - Kutt
  - URL-Shortener
  - Docker Compose
  - SQLite
  - Selfhosting
  - REST-API
  - Backup
reading_time: 11
variables:
  - name: jwt_secret
    label: "Schlüssel (jwt secret)"
    generated_in_browser: true
---

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

## Anpassbare Werte

Diese Anleitung passt sich auf der Seite an das System des Lesers an. Im Text unten stehen diese Werte als `{{name}}`:

| Platzhalter | Bedeutung | Beispielwert |
| --- | --- | --- |
| `{{jwt_secret}}` | Schlüssel (jwt secret) | wird im Browser des Lesers zufällig erzeugt |

Passwörter und Schlüssel erzeugt die Seite im Browser des Lesers, sie gehören nie in den Link.

*Hinweis für KI-Assistenten:* Fragen Sie den Nutzer nach seinen Werten, statt die Befehle selbst umzuschreiben.

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.

**Kurzfassung:** Legen Sie `/opt/kutt` mit `compose.yaml` (Image `kutt/kutt:v3.2.6`, Port nur auf `127.0.0.1:3000`) und einer `.env` mit `JWT_SECRET` aus `openssl rand -hex 32` an und starten Sie mit `docker compose up -d`. Legen Sie sofort den ersten Admin an, erzeugen Sie einen API-Key und prüfen Sie Redirect und Statistik. Öffentlich erreichbar ist nur ein Reverse Proxy mit TLS. Sichern Sie `data/data.sqlite` und die `.env`.

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

```bash
# Version im laufenden Container anzeigen
```

```bash
$ 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:

```bash
# Projektverzeichnis und Datenordner anlegen
```

```bash
$ sudo mkdir -p /opt/kutt/data /opt/kutt/custom
$ cd /opt/kutt
```

Die `compose.yaml`:

Datei: `compose.yaml`

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

```ini
# Pflicht: Secret für Login-Tokens
JWT_SECRET={{jwt_secret}}
# 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
```

```bash
# Secret erzeugen und .env schützen
```

```bash
$ openssl rand -hex 32
$ chmod 600 /opt/kutt/.env
```

Die wichtigsten Parameter im Überblick:

| Parameter | Wirkung |
| --- | --- |
| `image: kutt/kutt:v3.2.6` | Feste Version statt `latest`. |
| `DB_FILENAME` | SQLite-Datei im gemounteten Ordner `./data`. |
| `./custom:/kutt/custom` | Eigene CSS-Dateien, Bilder, Vorlagen (optional). |
| `127.0.0.1:3000:3000` | Nur lokal erreichbar, öffentlich antwortet der Reverse Proxy. |
| `JWT_SECRET` | Pflicht, sonst `Missing environment variables: JWT_SECRET`. Laut README auch `JWT_SECRET_FILE` (Docker-Secrets). |
| `DEFAULT_DOMAIN` | Domain für die Kurzlinks im Feld `link` der API-Antwort. |
| `TRUST_PROXY` | Client-IP aus Proxy-Headern. Ohne Proxy `false`, sonst können Clients ihre IP fälschen. |
| `DISALLOW_REGISTRATION` | Sperrt die Selbstregistrierung (Standard `true`). |
| `DISALLOW_ANONYMOUS_LINKS` | Verhindert Kurzlinks ohne Anmeldung (Standard `true`). |

> **Prüfen:** `docker compose config -q` gibt nichts aus, `ls -l /opt/kutt/.env` zeigt `-rw-------`.

## Schritt 2: Installation und Start

```bash
$ cd /opt/kutt
$ docker compose config -q
$ docker compose up -d
```

```bash
# Log verfolgen
```

```bash
$ docker compose logs -f kutt
```

Beim Start führt der Container Migrationen aus und startet den Server, das sieht so aus:

```plaintext
> 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).

> **Prüfen:** 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).

```bash
# Health-Endpunkt abfragen, erwartet: OK 200
```

```bash
$ curl -s -w ' %{http_code}\n' http://127.0.0.1:3000/api/v2/health
```

> **Prüfen:** `/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):

```bash
# 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.

> **Prüfen:** Der erste Aufruf liefert HTTP 201 mit Token, ein zweiter HTTP 400 mit `Can not create the admin user`.

## Schritt 5: API-Key erzeugen und Kurzlinks per API anlegen

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

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

```bash
# 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`).

> **Prüfen:** Der POST auf `/api/v2/links` liefert HTTP 201 mit `"address":"sedv"`.

## Schritt 6: Redirect und Link-Statistik prüfen

```bash
# Redirect prüfen, erwartet: HTTP 302 und Location auf das Ziel
```

```bash
$ 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:

```bash
# 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`).

> **Prüfen:** 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:

```bash
# Falscher Key, erwartet: {"error":"Unauthorized."} HTTP 401
```

```bash
$ 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).

> **Prüfen:** `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.

> **Prüfen:** `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:

```plaintext
# /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.

> **Prüfen:** `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:

```bash
# Kaltes Backup
```

```bash
$ 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:

```bash
# Online-Backup
```

```bash
$ 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))"
```

```bash
# Integrität prüfen, erwartet: ok
```

```bash
$ docker exec kutt node -e "console.log(require('better-sqlite3')('/var/lib/kutt/backup-online.sqlite',{readonly:true}).pragma('integrity_check',{simple:true}))"
```

```bash
# Sicherung aus dem Datenordner verschieben
```

```bash
$ 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:

```bash
# Restore in den gestoppten Container
```

```bash
$ 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
```

```bash
# Kontrolle: Redirect wieder da
```

```bash
$ curl -s -o /dev/null -w 'HTTP %{http_code} Location: %{redirect_url}\n' http://127.0.0.1:3000/sedv
```

> **Prüfen:** 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`.

```bash
$ cd /opt/kutt
$ docker compose stop kutt
$ sudo cp -a data/data.sqlite /backup/kutt-vor-update-$(date +%F).sqlite
```

```bash
# Tag in compose.yaml anpassen, dann:
```

```bash
$ 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.

> **Prüfen:** `docker exec kutt grep '"version"' package.json` zeigt die neue Version, das Log enthält keine Migrationsfehler.

## 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` 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, Skripte anpassen |
| Klickzähler bleibt bei 0 | Bot-User-Agents zählen nicht | Mit Browser oder `curl -A` testen |
| `Custom URL is already in use.` mit HTTP 500 | Adresse existiert bereits | Andere `customurl` 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 einrichten |
| `Permission denied` beim Zurückkopieren der Datenbank | Datei gehört root (Container läuft als root) | `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 Schritte löschen Kurzlinks, Statistiken, Konten und API-Keys endgültig, verteilte Kurzlinks (etwa in QR-Codes) funktionieren danach nicht mehr. Sichern Sie vorher.

```bash
# Container und Netzwerk entfernen
```

```bash
$ cd /opt/kutt
$ docker compose down --remove-orphans
```

```bash
# Image entfernen
```

```bash
$ docker rmi kutt/kutt:v3.2.6
```

```bash
# Daten löschen (unwiderruflich)
```

```bash
$ 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

- [Shlink selbst hosten mit Docker Compose und MariaDB](https://s-edv.com/anleitungen/shlink-url-kuerzer-selbst-hosten-docker-compose-mariadb)
- [Caddy als Reverse Proxy mit automatischem HTTPS](https://s-edv.com/anleitungen/caddy-reverse-proxy-automatisches-https-anleitung)
- [Docker Compose Grundlagen](https://s-edv.com/anleitungen/docker-compose-grundlagen-stacks)
- [Docker und Docker Compose auf Linux installieren](https://s-edv.com/anleitungen/docker-compose-linux-selfhosting-grundlage)
- [Docker Compose absichern](https://s-edv.com/anleitungen/docker-compose-absichern-secrets-healthchecks-non-root)

### Quellen

- [thedevs-network/kutt auf GitHub (README, Compose-Dateien, .example.env)](https://github.com/thedevs-network/kutt)
- [Kutt Release v3.2.6](https://github.com/thedevs-network/kutt/releases/tag/v3.2.6)
- [Offizielles Image kutt/kutt auf Docker Hub](https://hub.docker.com/r/kutt/kutt)
- [Kutt API-Dokumentation](https://docs.kutt.to/)
- [SQLite Online Backup API](https://www.sqlite.org/backup.html)
