Blog

  • Geschafft: 100 Punkte und eine Seite, die sich zu helfen weiß

    Die Seite ist online. Noch am selben Tag, direkt nach dem ersten Push, war Zeit für den ersten Blick von außen.

    Die Messung. Google bietet ein kostenloses Werkzeug an, PageSpeed Insights. Es ruft eine Seite auf und bewertet sie: Wie schnell lädt sie, wie gut lässt sie sich bedienen, wie sauber ist sie gebaut. Die Skala reicht bis 100. Bei mir stand dort in allen Bereichen die 100, mobil und am Desktop. Dazu kam eine 3 von 3 bei Agentic Browsing, einer Prüfung, wie gut KI-Programme mit der Seite zurechtkommen.

    Ich freue mich darüber, und ich ordne es ein: Die Seite hat noch keine Bilder. Wo nichts Schweres geladen werden muss, geht es leicht. Der Wert ist ein Ausgangspunkt. Nach den ersten Bildern und dem Design messe ich noch einmal.

    Das Inhaltsverzeichnis. Suchmaschinen finden eine neue Seite nicht von selbst. Sie freuen sich über eine Sitemap, ein Inhaltsverzeichnis, das alle Seiten auflistet. Yoast SEO erzeugt es, Simply Static exportiert es mit. Ich habe die Adresse bei der Google Search Console und bei den Bing Webmaster Tools eingetragen. Zusätzlich habe ich bei beiden die Startseite direkt zur Indexierung angemeldet, also darum gebeten, sie in den Suchindex aufzunehmen. Damit wissen beide, wo sie nachschauen können, und die Startseite steht ganz vorn in der Warteschlange.

    Die Seite für verirrte Besucher. Wer sich vertippt oder einem alten Link folgt, landet bei einer Adresse, die es nicht gibt. Der Fachbegriff dafür ist 404. Ohne eigene Seite zeigt der Hoster seine Standardmeldung, und die erzählt nichts von KISP.

    Also habe ich in WordPress eine schlichte Seite gebaut: ein Satz und vier Wege weiter, zur Startseite, zum Blog, zur Dokumentation und zum Glossar. Simply Static exportiert sie als 404.html.

    Beim Test gab es eine Überraschung. Die Datei lag auf dem Server, die Meldung des Hosters erschien trotzdem. Die Datei war da, der Webserver wusste nur nichts von ihr. Ihm fehlte ein Hinweis: „Wenn eine Seite fehlt, zeige diese hier.“ Der Hinweis passt in eine Zeile, in eine Datei namens .htaccess.

    Ein zweiter Stolperstein lag im Übertragungsskript. Es gleicht den Server mit dem Export ab und entfernt, was im Export fehlt. Eine Datei, die nur auf dem Server liegt, wäre beim nächsten Lauf verschwunden. Deshalb schreibt das Skript die .htaccess selbst mit. Danach hat es funktioniert: Eine erfundene Adresse zeigt jetzt die eigene Seite.

    Heute Abend folgt der nächste Schritt: der erste SEO-Audit. Er hält fest, wie die Seite am Tag des Go-live dasteht, als Nullpunkt für alles, was danach kommt.

    Zeig mir, wie es geht: 404-Seite und .htaccess
    • Seite in WordPress anlegen, Slug seite-nicht-gefunden, Inhalt kurz mit Links auf /, /blog/, /dokumentation/, /glossar/. Yoast: noindex, damit die Seite weder in Suchergebnissen noch in der Sitemap erscheint.
    • Simply Static Pro kennt dafür die Optionen generate_404 und custom_404_page. Dort wählst du die Seite als 404-Seite aus. Der Export enthält danach 404.html im Wurzelverzeichnis.
    • Der Webserver braucht den Hinweis. Bei All-Inkl genügt im Webroot eine .htaccess mit einer Zeile:
    ErrorDocument 404 /404.html
    • Das Übertragungsskript überträgt per rsync --delete-after. Eine Datei, die nur auf dem Server liegt, würde dabei gelöscht. Das Skript schreibt die .htaccess deshalb nach dem Bereinigen ins Arbeitsverzeichnis, sofern 404.html vorhanden ist:
    printf 'ErrorDocument 404 /404.html\n' > "$ARBEIT/.htaccess"
    • Prüfung mit einer erfundenen Adresse, erwartet sind Status 404 und der Text der eigenen Seite:
    curl -s -o /dev/null -w "%{http_code}\n" https://thekeepitsimpleproject.org/gibt-es-nicht-xyz
    curl -s https://thekeepitsimpleproject.org/gibt-es-nicht-xyz | grep -i "Seite nicht gefunden"
    • Ohne .htaccess liefert der Hoster zwar den Status 404, aber seine Standardseite (content-type: iso-8859-1).
    Zeig mir, wie es geht: Sitemap und Startseite einreichen
    • Die Adresse steht in der robots.txt: https://thekeepitsimpleproject.org/sitemap_index.xml.
    • Google: Search Console → Sitemaps → Adresse sitemap_index.xml eintragen.
    • Bing: Webmaster Tools → Sitemaps → vollständige Adresse eintragen.
    • Startseite direkt anmelden: Google Search Console → URL-Prüfung → https://thekeepitsimpleproject.org/ eingeben → „Indexierung beantragen“. Bing Webmaster Tools → URL-Übermittlung → Adresse eintragen. Die Menünamen können je nach Sprache und Version abweichen.
    • Verarbeitungsstatus und Zahl der gefundenen URLs lassen sich nach einigen Stunden ablesen. Ich halte sie in der SEO-Baseline fest.
  • Die Seite geht online

    Die Seite war fertig, und sie lag noch im Keller. Im Ordner staging auf meinem Heimserver, dem Cube, standen 71 Dateien, zusammen 2,6 Megabyte. Es fehlte nur noch der Weg zum Hoster, also zu der Firma, bei der die Seite für alle erreichbar im Netz steht.

    Der Weg heißt SSH. Das ist eine verschlüsselte Verbindung zu einem anderen Rechner, die sich statt mit einem Passwort mit einem Schlüsselpaar ausweist: Ein Schlüssel bleibt bei mir, sein Gegenstück liegt beim Hoster. Passen beide zusammen, öffnet sich die Tür.

    Auf dem Cube arbeite ich meist als root, der Verwalter mit allen Rechten. Für diese Aufgabe wollte ich das nicht. Wer nur Dateien verschicken soll, braucht keine Schlüssel zum ganzen Haus. Also entstand ein eigener Benutzer, kisp-deploy. Er kann sich nicht anmelden und hat keine Verwaltungsrechte. Er darf den Export lesen, den Schlüssel benutzen und mit dem Hoster sprechen.

    Der Schlüsselordner gehört einer Gruppe, die auch Protokolle lesen darf. In sie wollte ich kisp-deploy nicht aufnehmen. Ein schmales Durchgangsrecht auf dem übergeordneten Ordner löst das: Der Benutzer darf hindurchgehen, hineinschauen darf er nicht.

    Dann schickte ich den öffentlichen Schlüssel zum Hoster und prüfte die Verbindung. Sie stand beim ersten Versuch. Mein Claude hat daraufhin ein Skript geschrieben, das den Export kopiert, bereinigt und hochlädt. Das Hochladen übernimmt rsync. Das Programm schickt nur, was sich geändert hat, und entfernt auf dem Server, was es im Export nicht mehr gibt.

    Vor dem echten Lauf gab es einen Probelauf. Er zeigt, was passieren würde, und ändert nichts. Er nannte 71 Dateien zum Hochladen und eine zum Löschen: eine Platzhalterseite des Hosters mit dem Namen index.htm. Damit war klar, dass der Lauf nichts Wichtiges trifft.

    Dann kam der echte Lauf, und die Seite war da. Eine Sache fehlte noch: Im Backend stand ein Haken, der Suchmaschinen bat, die Seite zu übergehen. Er gehörte zur Bauphase. Ich habe ihn entfernt, neu exportiert und übertragen. Ein Blick in den Quelltext zeigte: Kein noindex mehr.

    Ergebnis: Die Seite ist unter https://thekeepitsimpleproject.org/ erreichbar, mit Zertifikat von Let’s Encrypt und einem Hinweis an Browser, nur noch verschlüsselt zu verbinden (HSTS, 180 Tage). Zugriffsprotokolle sind beim Hoster abgeschaltet.

    Zeig mir, wie es geht: Benutzer und Schlüssel
    • Benutzer ohne Shell und ohne sudo, als root:
    useradd --system --home-dir /var/lib/kisp-deploy --create-home --shell /usr/sbin/nologin kisp-deploy
    • Durchgangsrecht auf den Credentials-Ordner (root:adm, 750) statt Gruppenmitgliedschaft in adm:
    apt install acl
    setfacl -m u:kisp-deploy:--x /etc/terruhn.it/credentials
    getfacl /etc/terruhn.it/credentials

    Die Ausgabe enthält user:kisp-deploy:--x.

    • Eigener Unterordner und Schlüsselpaar (ed25519, ohne Passphrase, nur für diesen Zweck):
    install -d -m 0700 -o kisp-deploy -g kisp-deploy /etc/terruhn.it/credentials/kisp-deploy
    runuser -u kisp-deploy -- ssh-keygen -t ed25519 -N "" -C "kisp-deploy@cube" -f /etc/terruhn.it/credentials/kisp-deploy/id_ed25519_all-inkl
    cat /etc/terruhn.it/credentials/kisp-deploy/id_ed25519_all-inkl.pub

    Den öffentlichen Schlüssel trägst du im KAS des Hosters beim SSH-Benutzer ein.

    • Erste Verbindung mit eigener known_hosts, danach mit StrictHostKeyChecking=yes:
    runuser -u kisp-deploy -- ssh -i /etc/terruhn.it/credentials/kisp-deploy/id_ed25519_all-inkl \
      -o IdentitiesOnly=yes \
      -o UserKnownHostsFile=/etc/terruhn.it/credentials/kisp-deploy/known_hosts \
      -o StrictHostKeyChecking=accept-new \
      ssh-wXXXXXXXX@wXXXXXXXX.kasserver.com 'pwd; ls -la'

    Der SSH-Benutzer landet in einer eingeschränkten Umgebung mit / als Home; die Webdateien liegen unter /www/htdocs/<Kundenkennung>/<Domain>.

    • Verworfen: den Benutzer in die Gruppe adm aufnehmen (liest auch Protokolle) und den Schlüssel außerhalb von /etc/terruhn.it/credentials ablegen (bräche die Pfadkonvention).
    Zeig mir, wie es geht: Übertragung mit rsync
    • Skript export-uebertragen.sh (0.3) nach /usr/terruhn.it/sbin/, zusammen mit export-bereinigen.sh:
    install -m 0755 export-bereinigen.sh export-uebertragen.sh /usr/terruhn.it/sbin/
    • Ablauf: Staging per rsync -rlt --delete in ein Arbeitsverzeichnis (/var/lib/kisp-deploy/export) kopieren, dort export-bereinigen.sh ausführen, dann hochladen:
    rsync -rlt --delete-after --no-owner --no-group --chmod=D755,F644 --itemize-changes --stats \
      -e "ssh -i …/id_ed25519_all-inkl -o IdentitiesOnly=yes -o UserKnownHostsFile=…/known_hosts -o StrictHostKeyChecking=yes" \
      /var/lib/kisp-deploy/export/ ssh-wXXXXXXXX@wXXXXXXXX.kasserver.com:/www/htdocs/<Kundenkennung>/<Domain>/

    Die Kopie ist nötig, weil kisp-deploy im Staging nicht schreiben darf.

    • Schutz vor einem leeren Export: Abbruch, wenn index.html fehlt oder weniger als 20 Dateien im Staging liegen.
    • Probelauf und echter Lauf:
    runuser -u kisp-deploy -- /usr/terruhn.it/sbin/export-uebertragen.sh --dryrun
    runuser -u kisp-deploy -- /usr/terruhn.it/sbin/export-uebertragen.sh

    Der Probelauf nutzt rsync --dry-run; in der Ausgabe stehen mit *deleting die Dateien, die der echte Lauf löschen würde.

    • Kontrolle der Live-Seite:
    curl -sI https://thekeepitsimpleproject.org/ | grep -i "^HTTP\|x-robots\|strict-transport"
    curl -s https://thekeepitsimpleproject.org/ | grep -io noindex

    Erwartet: HTTP/2 200, strict-transport-security: max-age=15552000, keine Treffer für noindex.

    • Die Indexierungssperre (Einstellungen → Lesen) lässt sich per kisp-wp nicht ändern, option update steht nicht in der Allowlist. Das Entfernen des Hakens im Backend und ein neuer Export genügen.
  • Aufräumen nach dem Export

    Der Export war schlank, und ich las ihn zum ersten Mal wie ein Besucher von außen: Datei für Datei, mit dem Auge eines Suchprogramms.

    Dabei fiel mir etwas auf. In fast jeder Datei stand der Name des Helfers, der sie erzeugt hatte. Yoast SEO ist das Plugin, das sich um Suchmaschinen kümmert. Es hat seine Visitenkarte an vielen Stellen hinterlassen: als Satz in der llms.txt, als Kommentar am Ende jeder Sitemap, als Hinweis in der Anzeige der Sitemap und als Markierung in der robots.txt.

    Diese Dateien sind für Maschinen gedacht. Auf der fertigen Seite braucht niemand den Hinweis, mit welchem Werkzeug sie entstanden sind. Außerdem zeigt eine Seite, die ohne WordPress auskommt, ihre Herkunft besser durch das, was sie tut.

    Dazu kam eine Kleinigkeit am Anfang der llms.txt: ein unsichtbares Zeichen, das BOM. Es markiert eine Textdatei als Unicode und stört dort, wo Programme die erste Zeile genau lesen.

    Ich habe die Aufräumarbeit nicht von Hand erledigt. Ein kleines Skript macht es nach jedem Export in einem Zug. Zuerst durfte es an einer Kopie üben. Es hat die Spuren entfernt, die Sitemaps blieben gültig, und ein zweiter Lauf änderte nichts mehr. Danach habe ich es auf den echten Export losgelassen.

    Stehen geblieben ist nur eine CSS-Klasse im Quelltext der Seiten, yoast-schema-graph. Sie hat keine sichtbare Wirkung und trägt keine Information nach außen. Wer mag, kann sie später entfernen.

    Ergebnis: llms.txt, Sitemaps und robots.txt sind schlank und tragen keinen Werkzeugnamen mehr. Das Skript soll vor jeder Übertragung nach All-Inkl laufen.

    Zeig mir, wie es geht: Skript export-bereinigen.sh
    • Skript export-bereinigen.sh (0.1), Aufruf mit optionalem Verzeichnis, Standard ist das Staging:
    bash skripte/export-bereinigen.sh /data/terruhn.it/kisp/staging
    • Was es bereinigt:
    • llms.txt: BOM (EF BB BF) am Anfang und die Zeile Generated by Yoast SEO … samt folgender Leerzeile.
    • sitemap.xml: Schlusskommentar <!-- XML Sitemap generated by Yoast SEO -->.
    • main-sitemap.xsl: der Satz „Generated by Yoast SEO, this is an XML Sitemap …“ wird zu „This is an XML Sitemap …“.
    • robots.txt: die Zeilen # START YOAST BLOCK, # END YOAST BLOCK und die Trennlinien dazwischen.
    • Umsetzung mit perl -0pi bzw. perl -ni. Das Skript ist idempotent und prüft am Ende per grep -rIil yoast auf Reste in diesen Dateien (Exit-Code 1, wenn noch etwas steht).
    • Kontrolle:
    head -c 3 /data/terruhn.it/kisp/staging/llms.txt | xxd
    cat /data/terruhn.it/kisp/staging/robots.txt

    Die erste Zeile der Ausgabe beginnt mit 23 20 54 (# T) statt efbb bf.

    • Bewusst nicht angefasst: die Klasse yoast-schema-graph am Schema-Skript im HTML. Ein Ersetzen in jeder Seite brächte Aufwand ohne sichtbaren Nutzen.
    • Verworfen: die Texte schon in Yoast zu ändern. Die Zeilen entstehen beim Erzeugen der Dateien, und ein Eingriff dort würde vermutlich mit einem Plugin-Update überschrieben.
  • Zwei Türen und ein Wächter

    Heute, an Tag 2 des Projekts, stand bei WP Umbrella ein Wort, das ich nicht lesen wollte: getrennt. Gestern hatte die Verbindung noch gestanden, nach einigem Probieren.

    WP Umbrella ist mein Aufpasser für WordPress. Es prüft, ob die Seite läuft, und macht Sicherungen. Ohne Verbindung gibt es beides nicht.

    Ich schaute ins Protokoll meines Türstehers Caddy. Dort klopfte WP Umbrella von der Adresse 212.83.175.107 an und bekam ein Nein. Freigegeben hatte ich gestern nur eine einzige Adresse: 212.83.142.5. Der Dienst nutzt mehrere. Auf der Webseite von WP Umbrella steht die vollständige Liste, vier Adressen. Ich habe alle vier eingetragen. Die Ausnahme gilt nur, wenn Adresse und Name des Dienstes zusammen stimmen.

    Dann kam ein Gedanke: Adressen können sich ändern. Also habe ich mir einen Wächter gebaut. Er schaut jeden Morgen um 04:30 Uhr UTC in die Liste und vergleicht sie mit meiner Kopie. Er ändert nur etwas, wenn sich die Liste wirklich verändert hat. Eine leere oder schiefe Liste übernimmt er nicht.

    Beim Lesen des Protokolls fiel noch etwas auf: Die geheimen Schlüssel von WP Umbrella standen dort im Klartext, Zeile für Zeile. Ich habe Caddy beigebracht, sie wegzulassen, und für die Seite einen neuen Secret Token erzeugt. Der API-Key meines Kontos, den ich für alle Seiten nutze, blieb unberührt. Es sind zwei verschiedene Schlüssel.

    Die vier Adressen waren frei, und WP Umbrella blieb getrennt. Im Protokoll sah ich jetzt etwas anderes: Die Anfragen kamen an Caddy vorbei, und das Nein kam von Apache, dem Webserver hinter Caddy. Die Ablehnung kam aus WordPress selbst.

    Claude suchte im Quelltext der Plugins nach dem Satz aus der Antwort, realm="Website", und fand ihn in Simply Static Pro. Das Plugin hat eine eigene Passwortabfrage, und bei mir war sie eingeschaltet. WP Umbrella kommt ohne Passwort durch Caddy, und direkt dahinter fragt das Plugin noch einmal. Zwei Türen hintereinander, und nur eine kannte die Ausnahme.

    Ich habe den Schalter in Simply Static ausgeschaltet und einen Probe-Export gestartet. Er lief durch. Dann der Verbindungstest bei WP Umbrella: verbunden.

    Ergebnis: WP Umbrella ist verbunden, die Zugangsliste hält sich täglich selbst aktuell, das Protokoll enthält keine Schlüssel mehr. Was ich mitnehme: erst nachsehen, wer „Nein“ sagt, bevor ich an der falschen Tür weiterbaue.

    Zeig mir, wie es geht: IP-Liste und täglicher Wächter
    • Quelle der Liste: https://wp-umbrella.com/ips-v4/, eine Adresse pro Zeile. Stand 2026-10-06: 212.129.45.77, 212.83.142.5, 212.83.175.107, 62.4.26.31.
    • Skript umbrella-ips-check.sh (0.2) nach /usr/terruhn.it/sbin/, dazu Dienst und Timer (täglich 04:30 UTC, Persistent=true):
    install -m 0755 umbrella-ips-check.sh /usr/terruhn.it/sbin/umbrella-ips-check.sh
    install -m 0644 umbrella-ips-check.service umbrella-ips-check.timer /etc/systemd/system/
    systemctl daemon-reload
    systemctl enable --now umbrella-ips-check.timer
    • Ablauf des Skripts: laden, nur gültige IPv4-Zeilen behalten, sortieren, Anzahl prüfen (1 bis 50), mit der gespeicherten Liste vergleichen. Bei Änderung die Datei /srv/caddy/etc/umbrella-ips.caddy mit einer Zeile remote_ip … schreiben, caddy validate ausführen und erst dann caddy reload. Schlägt die Prüfung fehl, wird das alte Snippet wiederhergestellt.
    • Kontrolle im Journal:
    journalctl -u umbrella-ips-check.service --since today

    Erwartet: OK: keine Änderung (4 IPs).

    Zeig mir, wie es geht: Caddy mit Ausnahme und gefiltertem Protokoll
    • Im Matcher @geschuetzt des Site-Blocks steht der Import statt fester Adressen. Matcher innerhalb eines not { … }-Blocks bilden einen gemeinsamen Satz, die Ausnahme gilt also nur, wenn IP und User-Agent passen:
    @geschuetzt not {
        import umbrella-ips.caddy
        header_regexp ua User-Agent ^WPUmbrella
    }
    • Negativtest von einem Rechner außerhalb der Liste, erwartet 401:
    curl -s -o /dev/null -w '%{http_code}\n' -A 'WPUmbrella+test' https://thekeepitsimpleproject.org/
    • Das Zugriffsprotokoll enthielt die Header X-Secret-Token, X-Authorization und die Signaturen. Filter im Site-Block:
    log {
        format filter {
            wrap json
            request>headers>X-Secret-Token delete
            request>headers>X-Authorization delete
            request>headers>X-Umbrella-Signature delete
            request>headers>X-Umbrella-Signature-V2 delete
        }
    }
    • Kontrolle, erwartet 0:
    docker logs caddy --since 5m 2>&1 | grep -c -E 'X-Secret-Token|X-Umbrella-Signature'
    • Neuer Secret Token pro Seite: /wp-admin/options-general.php?page=wp-umbrella-settings&support=1, Schaltfläche „Regenerate Secret Token“, danach im Konto neu synchronisieren. Der API-Key des Kontos bleibt davon unberührt.
    • Beim Prüfen half, dass Caddy die echte Absender-IP sieht (remote_ip im Log). Der Kommentar „greift derzeit nicht“ im Caddyfile war veraltet.
    Zeig mir, wie es geht: Die zweite Tür in Simply Static Pro
    • Befund im Caddy-Protokoll: Anfragen von 212.83.142.5 kamen mit Server: Apache an, Status 401, Header WWW-Authenticate: Basic realm="Website". Die Ablehnung kam also nicht von Caddy.
    • Fundstelle: wp-content/plugins/simply-static-pro/src/misc/class-ssp-basic-auth.php. Die Klasse prüft bei init, ob http_basic_auth_on gesetzt ist, und erwartet PHP_AUTH_USER und PHP_AUTH_PW aus den Simply-Static-Einstellungen. Ausgenommen sind nur die Pfade ssp-form, wp-json, graphql, freemius, wordpress.org und wp-cron.php. WP Umbrella ruft / und /index.php?rest_route=… auf, beides ist nicht ausgenommen.
    • Die Anfragen von WP Umbrella tragen ohne Caddy-Anmeldung keinen Authorization-Header. Die Prüfung im Plugin schlägt deshalb zu.
    • Lösung: In den Simply-Static-Einstellungen „HTTP Basic Auth“ ausschalten, Benutzername und Passwort stehen lassen. Der Export liest die Zugangsdaten getrennt davon (Util::get_basic_auth_header_for_url) und läuft weiter durch.
    • Verworfene Alternative: Ein Must-Use-Plugin mit dem Filter ssp_basic_auth_enabled, das die Prüfung nur für den User-Agent WPUmbrella abschaltet. Es hätte die Prüfung für alle anderen Zugriffe erhalten und braucht dafür eine zusätzliche Datei. Der Schalter ist die einfachere Variante und lässt sich jederzeit zurückstellen.
    • Kontrolle nach dem Verbindungstest, erwartet 200:
    docker logs caddy --since 5m 2>&1 | grep -E '"remote_ip":"(212\.129\.45\.77|212\.83\.142\.5|212\.83\.175\.107|62\.4\.26\.31)"' | grep -oE '"remote_ip":"[^"]*"|"uri":"[^"]*"|"status":[0-9]+'
  • Die Diagnose und der Wecker

    Simply Static hat eine Diagnose-Seite. Sie prüft, ob alles bereit ist: Schreibrechte, PHP-Version, Berechtigungen der Datenbank. Eine Zeile blieb rot: WP-CRON. „WordPress cron is not available and not running“, stand dort.

    Ich hatte den Cron selbst ausgeschaltet. Cron ist der Wecker von WordPress. Er stößt geplante Aufgaben an, zum Beispiel den Export. Standardmäßig tickt er nur, wenn jemand die Seite besucht. Auf meinem Server übernimmt das ein Systemdienst, der alle fünf Minuten klingelt. Den eingebauten Wecker habe ich dafür abgestellt.

    Die Diagnose sieht nur den abgestellten Wecker und schlägt Alarm. Von meinem Systemdienst weiß sie nichts. Im Quelltext des Plugins stand ein Weg, ihr das mitzuteilen: eine Konstante namens SS_CRON in der wp-config.php, der Konfigurationsdatei von WordPress. Eine Einstellung in der Oberfläche hatte ich zuerst nicht gefunden, also schrieb ich die Konstante dort hinein. Die Zeile wurde grün.

    Später fiel mir auf: Simply Static hat dafür einen eigenen Schalter, ganz unten im Bereich „Debug“. Er sagt dem Plugin dasselbe, nur an einer Stelle, an der man es sucht. Ich habe ihn eingeschaltet und die Konstante wieder gelöscht. Die Zeile blieb grün. Eine Sorge weniger in der Konfigurationsdatei, und die Einstellung steht dort, wo sie hingehört.

    Zeig mir, wie es geht: Server-Cron im Debug-Bereich
    • Die Prüfung steht in simply-static/src/class-ss-diagnostic.php, Funktion is_wp_cron_running(). Sie meldet einen Fehler, wenn DISABLE_WP_CRON === true gilt und weder die Konstante SS_CRON noch die Option server_cron gesetzt ist.
    • Der Schalter heißt „Server-side cron job“ und steht in Simply Static unter Einstellungen → Debug, ganz unten. Die Beschriftung kann je nach Sprache und Version abweichen. Nach dem Einschalten speichern.
    • Das Plugin definiert SS_CRON selbst, sobald die Option server_cron aktiv ist (simply-static.php, Zeile 92 f.). Steht die Konstante zusätzlich in der wp-config.php, meldet WP-CLI bei jedem Aufruf Constant SS_CRON already defined. Das ist der Hinweis, dass die Zeile in der Datei entfallen kann.
    • Zeile entfernen:
    vi /data/terruhn.it/kisp/wordpress/wp-config.php

    Die Zeile define( 'SS_CRON', true ); löschen und speichern.

    • Syntax prüfen:
    docker compose -f /data/terruhn.it/kisp/compose.yaml exec wordpress php -l /var/www/html/wp-config.php
    • Simply Static → Diagnose neu laden. Zeigt die Seite noch den alten Stand, liegt das am zwischengespeicherten Ergebnis.
    • DISABLE_WP_CRON bleibt in der wp-config.php stehen. Der Systemdienst kisp-wp-cron.timer übernimmt weiterhin alle fünf Minuten.
  • Aus 80 Megabyte werden 2,4

    Beim ersten Export hatte ich 1.819 Dateien und 81 Megabyte. Für eine Seite aus Text ist das viel. Nach dem zweiten Anlauf waren es 1.782 Dateien und 80 Megabyte. Ich hatte die Autorenseiten und die Ordner der Plugins abgewählt, und doch rührte sich das Gewicht kaum. Ein Ordner blieb hartnäckig: wp-includes, die Werkzeugkiste, die WordPress für seinen Redaktionsbereich braucht. Darin lagen 1.638 Dateien mit 60 Megabyte JavaScript. Auf der fertigen Seite verlinken nur zwei davon.

    Ich habe den Ordner ausgeschlossen und den Export wiederholt. Der Ausschluss hatte keine Wirkung: Die 1.638 Dateien lagen wieder im Staging.

    Also habe ich nachgesehen, statt weiter zu raten. Simply Static ist ein Plugin, und sein Quelltext liegt offen auf meinem Server. Dort steht ein Crawler, ein kleiner Sammler, der alle Dateien aus wp-includes in die Liste einträgt. Er ist in meiner Crawler-Liste ausgeschaltet und lief doch mit, weil eine andere Einstellung ihn anschaltet: Smart Crawl. Sie soll dafür sorgen, dass nichts Wichtiges fehlt, und tut das gründlich. Warum meine Ausschlussliste das nicht verhinderte, habe ich nicht abschließend geklärt.

    Die Lösung war eine einzige Einstellung: Smart Crawl aus. Dazu kam eine zweite Entscheidung: Den Ausschluss von wp-includes habe ich wieder entfernt, damit verlinkte Dateien nicht versehentlich fehlen. Die Seiten verlinken ihre Dateien selbst, und Simply Static holt, was verlinkt ist.

    Dann kamen noch zehn kleine Schalter im Bereich „Verstecken“ und „Deaktivieren“. Sie nehmen Hinweise aus dem Quelltext, die auf einer statischen Website niemand braucht: die WordPress-Version, einen Generator-Eintrag, einen Verweis auf eine Schnittstelle, die es dort nicht gibt, und eine Adresse bei s.w.org. Die Adresse hätte jeden Besuch mit einem fremden Server verbunden. Bei KISP soll nichts nach außen telefonieren.

    Dann das Ergebnis: 62 Dateien, 2,4 Megabyte. Rund dreißigmal kleiner. Aus wp-includes sind 23 Dateien übrig, zusammen 264 Kilobyte. Im Quelltext stehen keine Hinweise auf WordPress-Version, Generator oder fremde Server mehr. Ich habe mir das Staging im Browser angesehen. Es sieht aus wie die Werkstatt.

    Das ist eine Wegmarke. Es gibt jetzt einen Export, den ich in dieser Form ins Internet legen könnte: klein, ohne fremde Verbindungen und ohne Spuren der Technik dahinter. Wie er zu All-Inkl kommt und was an kleinen Dingen noch fehlt, ist die nächste Strecke. In der llms.txt stehen noch ein unsichtbares Zeichen am Anfang und eine Zeile von Yoast. Beides räume ich als Nächstes weg.

    Zeig mir, wie es geht: die Einstellungen im Überblick
    • Simply Static → Einstellungen, Bereich Crawling: Smart Crawl ausschalten. Smart Crawl erzwingt den Crawler „Includes Directory“, unabhängig von der Crawler-Liste.
    • Aktive Crawler: home, pagination, post_type, sitemap, taxonomy, text_file, theme_assets, uploads.
    • Auszuschließende URLs: /author/ und /wp-content/plugins/. Den Eintrag /wp-includes/ entfernen.
    • Zusätzliche URLs (Arbeitsinstanz): llms.txt und sitemap_index.xml, weil Yoast sie zur Laufzeit erzeugt.
    • Zielverzeichnis vor dem Export leeren: an. Damit spiegelt das Staging genau den letzten Export.
    • Verstecken: WordPress-Version, Generator-Meta, DNS-Prefetch-Link, RSD-Header.
    • Deaktivieren: XML-RPC, Embed-Skripte, DB-Debug, WLW-Manifest-Skripte, Emojis.
    Zeig mir, wie es geht: den Verursacher finden
    • Einstellungen auslesen (Plugin-Option als JSON):
    sudo -n /usr/terruhn.it/sbin/kisp-wp option get simply-static --format=json

    Dort stand smart_crawl = '1'.

    • Im Quelltext des Plugins nachsehen, wann der Crawler aktiv ist:
    sed -n 30,45p /data/terruhn.it/kisp/wordpress/wp-content/plugins/simply-static/src/crawler/class-ss-wp-includes-crawler.php

    In is_active() steht: Bei eingeschaltetem smart_crawl ist der Crawler aktiv, auch wenn er nicht in der Crawler-Liste steht.

    • Beobachtung: Mit eingeschaltetem Smart Crawl und dem Ausschluss /wp-includes/ lagen weiterhin alle 1.638 Dateien im Staging. Den Grund dafür habe ich nicht abschließend geklärt. Smart Crawl und Ausschluss habe ich im selben Schritt geändert, deshalb kenne ich den Einzelbeitrag beider Änderungen nicht.
    Zeig mir, wie es geht: den Export prüfen
    • Dateien und Größe:
    cd /data/terruhn.it/kisp/staging
    find . -type f | wc -l; du -sh .
    find wp-includes -type f | wc -l; du -sh wp-includes
    • Hinweise im Quelltext zählen (Ergebnis je Suchbegriff: 0):
    for t in 's.w.org' 'name="generator"' 'rel="EditURI"' 'wp-emoji' 'xmlrpc'; do
      echo "$t: $(find . -name '*.html' -print0 | xargs -0 grep -l -F -- "$t" | wc -l)"
    done
    • Verlinkte Dateien, die im Staging fehlen:
    grep -rhoE '(src|href)="https://thekeepitsimpleproject.org/[^"? #]+' --include=index.html . \
      | sed -E 's#^(src|href)="https://thekeepitsimpleproject.org/##' | sort -u \
      | grep -E '\.(css|js|png|jpg|svg|woff2?|ico|xml|txt)$' | while read f; do [ -e "$f" ] || echo "FEHLT: $f"; done
    • Ergebnis: 62 Dateien, 2,4 MB. In wp-includes bleiben 21 Block-Stile (CSS) und die zwei Menü-Skripte.
    • Nicht jedes Treffer-Wort ist ein Fund: wp-embed-responsive ist nur eine Klasse am Seitenrumpf, kein Skript.
  • Der erste Export

    Bis hierher war KISP eine Werkstatt: ein WordPress, das nur ich sehen kann. Heute passiert das, worauf alles hinausläuft. Aus den Seiten, die ich dort geschrieben habe, entstehen fertige Dateien, wie sie später im Internet liegen. Dafür gibt es ein Werkzeug namens Simply Static Pro. Es besucht jede Seite meiner Werkstatt, schreibt sie als einfache Datei ab und legt alles in einen Ordner. Dieses Abschreiben heißt Export, der Ordner heißt Staging, also eine Bühne hinter dem Vorhang, auf der noch niemand zuschaut.

    Simply Static brauchte einen Zugang, denn meine Werkstatt hat eine Tür mit Passwort. Statt mein eigenes Passwort zu hinterlegen, habe ich einen eigenen Besucher angelegt, der nur für den Export da ist. Sollte er jemals in falsche Hände geraten, lässt er sich mit einer Zeile löschen.

    Dann kam der erste Lauf: 1.819 Dateien, 81 Megabyte. Das ist viel für eine Seite aus Text. Beim Nachsehen zeigte sich, woher das Gewicht stammt. Zu 70 Megabyte tragen Werkzeuge bei, die WordPress nur für den Redaktionsbereich braucht. Auf der fertigen Seite lädt gerade einmal eine Handvoll davon. Dazu kamen Autorenseiten, die ich gar nicht haben wollte, und die Ordner der Plugins.

    Gut zu wissen, bevor die Seite ins Internet geht: Die Sperre für Suchmaschinen ist im Export angekommen, die Links zeigen auf die künftige Adresse thekeepitsimpleproject.org, und die Dateien für KI-Assistenten (llms.txt) und Suchmaschinen (Sitemap) liegen bereit. Auch zwei Kleinigkeiten sind aufgefallen. Ein unsichtbares Zeichen am Anfang der llms.txt, das BOM, und Yoast-Kommentare im Quelltext.

    Der zweite Export brachte 1.782 Dateien und 80 Megabyte. Autorenseiten und Plugin-Ordner waren weg, wp-includes mit 1.638 Dateien geblieben. Warum das so war und wie die Seite am Ende auf 62 Dateien und 2,4 Megabyte schrumpfte, erzähle ich im nächsten Beitrag.

    Wie die fertigen Dateien zu All-Inkl gelangen, folgt in einer späteren Etappe.

    Zeig mir, wie es geht: Simply Static Pro per WP-CLI installieren
    • Die ZIP-Datei war größer als die Upload-Grenze von WordPress. Sie liegt kurz in /data/terruhn.it/kisp/wordpress/, das im WP-CLI-Container als /var/www/html eingebunden ist:
    chown 33:33 /data/terruhn.it/kisp/wordpress/<datei>.zip
    cd /data/terruhn.it/kisp
    docker compose -p kisp run --rm wpcli wp plugin install simply-static --activate
    docker compose -p kisp run --rm wpcli wp plugin install /var/www/html/<datei>.zip --activate
    rm /data/terruhn.it/kisp/wordpress/<datei>.zip
    • Versionen: Simply Static 3.8.16, Simply Static Pro 2.6.11. Die Pro-Version setzt die freie Version voraus.
    • Der Lizenzschlüssel steht im Passwortmanager und nicht im Projektordner.
    Zeig mir, wie es geht: eigener Zugang für den Export
    • In Caddy eine zweite Zeile im Block basic_auth @geschuetzt, Benutzer simplystaticexport mit eigenem Passwort (caddy hash-password).
    • Test:
    curl -sI -u simplystaticexport:'<PASSWORT>' https://thekeepitsimpleproject.org/ | head -1

    Erwartet HTTP/2 200.

    • In Simply Static unter Allgemein den Basic-Auth-Benutzer und das Passwort eintragen.
    • Exportziel: Bereitstellung → Lokales Verzeichnis /var/www/staging (auf dem Cube /data/terruhn.it/kisp/staging, Eigentümer www-data).
    • Ziel-URL: Absolute URLs mit https://thekeepitsimpleproject.org.
    • Zusätzliche URLs: /llms.txt und /sitemap_index.xml der Arbeitsinstanz, weil Yoast sie zur Laufzeit erzeugt.
    Zeig mir, wie es geht: den Export verschlanken
    • Erweitertes Crawling, aktive Crawler: ausgeschaltet wurden Autoren-URLs, Includes-Verzeichnis, Plugin-Assets, Vendor-Dateien und Konfigurationsdateien.
    • Einschließen/Ausschließen: /author/ und /wp-content/plugins/.
    • Ergebnis nach dem zweiten Lauf: Autorenseiten und Plugin-Ordner sind weg, wp-includes ist mit 1.638 Dateien (60 MB JavaScript) geblieben.
    • Die Seiten verlinken aus wp-includes nur zwei Dateien: js/dist/script-modules/block-library/navigation/view.min.js und js/dist/script-modules/interactivity/index.min.js.
    • Prüfbefehl:
    grep -rhoE '(src|href)="[^"]*wp-includes[^"]*"' --include=index.html . | sed 's/.*="//;s/"$//;s/?.*//' | sort -u
    • Welche Einstellung wp-includes weiter einbezog und wie der Ordner aus dem Export verschwand, steht im nächsten Beitrag.
    • Das BOM in der llms.txt (ef bb bf) und die Yoast-Kommentare räume ich in einem späteren Schritt weg.
  • Erzähl deine Geschichte selbst

    Stell dir vor, jemand fragt einen KI-Assistenten nach deinem Projekt. Der Assistent antwortet in zwei Sätzen, freundlich und sicher im Ton. Was in diesen Sätzen steht, hat er irgendwo gelesen. Und wenn deine Seite für ihn schwer lesbar war, hat er sich die Geschichte bei jemand anderem geholt.

    Daraus wächst das Motto dieser Etappe: Erzähl deine Geschichte selbst. Wer keine eigene, klar lesbare Quelle anbietet, überlässt die Erzählung anderen. Suchmaschinen und KI-Assistenten sind dafür die wichtigsten Leser der kommenden Jahre. Sie arbeiten langsam: Was sie heute verstehen, prägt über Monate, was Menschen finden. Das Papier „Gemeinsam kann mehr“ hat mich darin bestärkt. Es beschreibt, wie eine Stadt ihre Geschichte mit eigenen Kanälen, eigenen Daten und eigener Technik selbst erzählt, und es bringt das auf drei Gedanken, die ich für KISP übernehme.

    Erstens: Zugänglichkeit ist die Grundlage. Ein klar gegliederter Text hilft Menschen mit Screenreader, mit wenig Zeit, mit Lernschwierigkeiten oder mit müden Augen. Dieselbe Klarheit hilft Suchmaschinen und KI: eindeutige Überschriften, kurze Sätze, erklärte Fachbegriffe, Datum und Version. Was für Menschen verständlich ist, ist für Maschinen lesbar. Das Thema fällt oft unter den Tisch, obwohl es mehrere Ziele auf einmal erreicht. Bei KISP steht es deshalb am Anfang und nicht am Ende. Die Prüfung der Barrierefreiheit folgt mit dem Inhalt der Seite.

    Zweitens: Eigene Technik, eigene Daten. Die Seite wird statisch ausgeliefert: schnell, sicher, ohne Cookies, ohne fremde Skripte. Dazu passt, dass ich Messwerkzeuge und Plugins meide, die Besucher an Dritte weiterreichen.

    Drittens: Offen und strukturiert. Gute Quellen erklären, wie sie entstanden sind. Genau das tut dieser Blog. KI-Systeme geben klare Aussagen korrekt wieder oder lassen sie weg. Ein sauberer Aufbau entscheidet also mit, ob deine Worte in einer Antwort auftauchen.

    Yoast als Werkzeug

    Für die Umsetzung installiere ich das Plugin Yoast SEO. Es hilft, Titel und Beschreibungen so zu schreiben, dass Suchmaschinen sie gut lesen, und es liefert die Sitemap, eine Art Inhaltsverzeichnis für die Seite.

    Yoast bringt viel mit, und vieles davon braucht KISP nicht: Werbung für die Bezahlversion, Verbindungen zu fremden Diensten, Funktionen für große Shops. Ich gehe die Liste durch und lasse nur an, was zur Idee passt: schlank, ohne Cookies, ohne Anrufe bei fremden Servern. Die Hilfen für KI-Texte bleiben aus, weil Claude die Texte ohnehin selbst schreibt.

    Eine Datei bleibt bewusst an: die llms.txt. Sie ist ein kleines Inhaltsverzeichnis, das sich an KI-Assistenten richtet. Dort steht, welche Seiten es gibt und worum es geht. Es ist ein Stück „Geschichte selbst erzählen“ in Dateiform. Auch die Schema-Angaben bleiben an. Das sind unsichtbare Etiketten im Quelltext, die Maschinen sagen: Das ist ein Artikel, das ist die Startseite, das ist der Name der Seite.

    Gesperrt, bis die Geschichte steht

    Solange die Seite noch leer ist, bleibt sie für Suchmaschinen gesperrt. Ein Haken in WordPress erledigt das. Der Haken wird vor dem öffentlichen Start wieder entfernt, und dieser Punkt steht fest auf dem Board.

    Google und Bing kennenlernen

    Dann geht es an die Anmeldung. Bei Google heißt das Werkzeug Search Console. Damit Google glaubt, dass die Domain mir gehört, schreibe ich einen kurzen Text in den Namensdienst der Domain, einen TXT-Eintrag im DNS. Das dauerte wenige Minuten, dann stand das Häkchen.

    Bei Bing lief es ähnlich, mit einem CNAME-Eintrag. Bing ist wichtig, weil auch DuckDuckGo, Ecosia und KI-Assistenten auf seine Daten zurückgreifen. Wer nur an Google denkt, lässt einen großen Teil der Leser aus.

    Was ich ablehne

    Das Google-Plugin Site Kit installiere ich nicht. Es würde Skripte und Cookies auf die Seite bringen, und beides braucht KISP nicht. Die Search Console funktioniert auch ohne. Ebenso habe ich ein Plugin verworfen, das Yoast-Werbung ausblendet: ein weiteres Plugin für eine Aufgabe, die sich später beim Export erledigen lässt.

    Denn eine Kleinigkeit bleibt: Yoast hinterlässt Spuren im Quelltext, in der Sitemap und in der llms.txt. Beim Export der statischen Seite lasse ich sie von Simply Static entfernen.

    Ergebnis: Yoast SEO ist schlank eingerichtet, die Indexierung ist gesperrt, Google und Bing kennen die Domain. Der Weg ist vorbereitet, damit die Geschichte später dort ankommt, wo gefragt wird.

    Zeig mir, wie es geht: Yoast installieren und schlank stellen
    • Installation im Stack-Ordner /data/terruhn.it/kisp:
    docker compose -p kisp run --rm wpcli wp plugin install wordpress-seo --activate
    docker compose -p kisp run --rm wpcli wp plugin list --fields=name,status,version

    Der Wrapper kisp-wp erlaubt bewusst keine Plugin-Befehle. Das gehört zum Zugriffskonzept von Claude.

    • Yoast SEO v28.6, Einstellungen → Allgemein → Website-Funktionen:
    • an: SEO-Analyse, Lesbarkeitsanalyse, Cornerstone-Inhalt, Schema-Framework, XML-Sitemaps, Open Graph, X-Kartendaten, llms.txt (Automatik)
    • aus: Yoast KI, Schema-Aggregationspunkt, Einblicke, Inklusive Sprachanalyse, Text-Link-Zähler, REST-API-Endpunkt, Slack-Freigabe, Aufgabenliste, Admin-Leiste
    • Nutzungsdaten teilen: aus
    • Integrationen (Semrush, Wincher, Zapier, Algolia und weitere): aus, sie sprechen externe Dienste an.
    • Website-Grunddaten: Name „The Keep IT Simple Project“, alternativer Name „KISP“, Trennzeichen „–“.
    • Vorlagen für Beiträge und Seiten: SEO-Titel %%title%% %%sep%% %%sitename%%; Beitrags-Beschreibung %%excerpt%%.
    • Aus der Suche genommen: Kategorien, Schlagwörter, Autoren-, Datums- und Formatarchive, Medienseiten.
    • Indexierungssperre: Einstellungen → Lesen → „Suchmaschinen davon abhalten …“. Der Wert blog_public steht auf 0:
    sudo -n /usr/terruhn.it/sbin/kisp-wp option get blog_public
    Zeig mir, wie es geht: Search Console und Bing
    • Search Console, Property-Typ Domain: Eingabe thekeepitsimpleproject.org (ohne Protokoll und ohne www). Google nennt einen Wert google-site-verification=….
    • Im KAS von All-Inkl unter den DNS-Einstellungen der Domain einen Eintrag anlegen: Host leer (Hauptdomain), Typ TXT, Wert wie von Google genannt. Danach in der Search Console „Bestätigen“.
    • Bing Webmaster Tools: Bestätigung per DNS mit einem CNAME-Eintrag, den Bing vorgibt. Die Property lässt sich auch aus der Search Console importieren.
    • Das Meta-Tag im <head> wäre ein weiterer Weg und würde auch aus der statischen Seite exportiert. Es gilt für eine URL-Präfix-Property.
    • Die Sitemap (/sitemap_index.xml) wird erst nach dem öffentlichen Start eingereicht.
    Zeig mir, wie es geht: Yoast-Spuren entfernen
    • Yoast schreibt Kommentare in den <head>, einen Hinweis in die Sitemap und die Zeile „Generated by Yoast SEO“ in die llms.txt.
    • Geplanter Weg: „Suchen und Ersetzen“ in Simply Static Pro beim Export. Verworfen wurden ein Fremd-Plugin (Toasty Purge) und eine eigene Datei im Ordner mu-plugins.
    • Die automatische llms.txt listet bisher nur die Startseite und die Kategorie „Etappen“, die Links zeigen auf die Arbeitsinstanz. Beim Export wird geprüft, ob die Adressen umgeschrieben werden; bei Bedarf stellt die Auswahl auf manuell.
  • Eine Seite, die sich selbst auf dem Laufenden hält

    Wer eine Werkstatt besucht, möchte wissen, welche Maschinen dort stehen. Bei KISP ist die Werkstatt ein kleiner Server bei mir zu Hause, und die Maschinen heißen WordPress, PHP und Datenbank. Ihre Namen und Versionsnummern gehören zu den Dingen, die sich ändern, ohne dass jemand es merkt. Ein Update hier, ein neues Plugin dort.

    Eine handgeschriebene Liste wäre schnell überholt. Deshalb habe ich die Seite Technik des Backends so gebaut, dass sie sich selbst schreibt. Ein kleines Skript fragt WordPress, was gerade läuft, und trägt die Antworten in eine Tabelle ein. Ein Wecker auf dem Server startet es jede Nacht um kurz nach vier. Hat sich nichts geändert, bleibt die Seite unberührt.

    Wichtig war mir, was nicht auf der Seite steht: keine Ordner, keine Zugangsdaten, keine Schlüssel. Das Skript gibt nur aus, was ich ihm ausdrücklich erlaube, eine Positivliste. Alles andere bleibt unsichtbar, auch wenn WordPress es wüsste.

    Und die KI? Sie hat das Skript mit mir geschrieben. Den täglichen Lauf übernimmt sie nicht. Fakten abzulesen und in eine Tabelle zu setzen braucht kein Nachdenken, und Skripte verbrauchen keine Tokens.

    Ergebnis: Die Seite Technik des Backends ist veröffentlicht, ein Hinweis auf der Seite nennt die automatische Aktualisierung, und der Wecker läuft.

    Zeig mir, wie es geht: Skript und Timer
    • Skript skripte/technik-seite.sh, auf dem Cube unter /usr/terruhn.it/sbin/kisp-technik-seite.sh (root, 750). Es legt die Seite beim ersten Lauf an (--post_status=publish, Slug technik, Autor claude) und aktualisiert sie danach.
    • Datenquelle: wp eval mit eigenen Abfragen (get_bloginfo, wp_get_theme, get_plugins, get_mu_plugins, $wpdb->db_version()). WP_Debug_Data::debug_data() wird nicht verwendet, weil es Pfade und Datenbank-Host enthält.
    • Änderungserkennung: SHA-256 des erzeugten Markups als Post-Meta _kisp_technik_hash. Das Datum im Seitenfuß geht erst nach dem Hash ein, die Seite ändert sich also nur bei geänderten Angaben.
    • PHP-Werte aus dem Web-Container, an wp eval per Umgebungsvariable übergeben:
    KISP_WEB="$(docker compose -p kisp exec -T wordpress php -r 'echo json_encode([...]);')"
    docker compose -p kisp run --rm -T -e KISP_WEB="$KISP_WEB" wpcli wp --user=claude eval "$PHP"

    Der WP-CLI-Container meldete memory_limit -1, max_execution_time 0 und eine andere PHP-Patchversion (8.3.35 statt 8.3.30).

    • Systemd-Einheiten infra/kisp-technik.service und infra/kisp-technik.timer, installiert unter /etc/systemd/system/:
    systemctl daemon-reload
    systemctl enable --now kisp-technik.timer
    systemctl list-timers kisp-technik.timer

    Zeitplan: OnCalendar=--* 04:15:00, Persistent=true, RandomizedDelaySec=5min.

    • Trockenlauf ohne Schreiben: kisp-technik-seite.sh --dry-run.
  • Ein Schlüssel für Claude, mit engen Grenzen

    Bisher habe ich alle Texte selbst in WordPress eingetragen. Das geht schneller, wenn Claude, mein KI-Helfer, die Beiträge direkt anlegen darf. Dafür braucht er einen Zugang zum Server. Einen Generalschlüssel möchte ich ihm nicht geben.

    Stattdessen bekommt Claude einen Schlüssel, der nur wenige Türen öffnet: ein kleines Skript. Es kennt eine Liste erlaubter Handgriffe, zum Beispiel „Beitrag anlegen“, „Beitrag lesen“ oder „Beitrag in den Papierkorb legen“. Alles andere lehnt es höflich ab. Auch Befehle, mit denen sich beliebiger Code ausführen ließe, bleiben draußen.

    Zum Test habe ich eine Seite anlegen, ändern und in den Papierkorb legen lassen. Das hat funktioniert. Die verbotenen Befehle kamen mit einer klaren Meldung zurück. Neue Beiträge landen zuerst als Entwurf. Veröffentlicht wird, wenn ich es freigebe.

    Zeig mir, wie es geht: Der Wrapper
    • Wrapper /usr/terruhn.it/sbin/kisp-wp (root:root, 755) startet docker compose -p kisp run --rm -T wpcli wp ….
    • Allowlist nach Unterbefehl-Paaren: post create|get|list|update|delete|exists, post meta get|list|add|update|delete, term …, menu …, menu item …, media import, option get.
    • Gesperrt: --exec, --require, --ssh, --http, --path, --config, --allow-root, --prompt, --user; Optionen vor dem Unterbefehl; alle nicht gelisteten Unterbefehle (eval, eval-file, shell, db, package, plugin, option update …).
    • sudo-Regel /etc/sudoers.d/kisp-wp: rene ALL=(root) NOPASSWD: /usr/terruhn.it/sbin/kisp-wp, geprüft mit visudo -cf.
    • Inhalt per STDIN: echo '…' | sudo -n /usr/terruhn.it/sbin/kisp-wp post create - --post_status=draft --porcelain.
    • Löschen ohne --force legt in den Papierkorb.

    Ein eigener Name für Claude

    Nach den ersten Beiträgen fiel mir etwas auf. Bei keinem Text stand, wer ihn angelegt hatte. Das Feld für den Autor war leer. Für eine Seite, die offen mit KI arbeitet, passt das nicht.

    Claude hat deshalb ein eigenes Benutzerkonto in WordPress bekommen, mit dem Namen „Claude (KI)“. Das Skript meldet sich bei jedem Handgriff mit diesem Konto an. Einen anderen Namen kann Claude dabei nicht angeben. So trägt jeder Text, den Claude anlegt, diesen Autor. Die bisherigen Beiträge habe ich nachträglich umgestellt.

    Das Konto darf Beiträge und Seiten schreiben. Plugins, Designs und andere Benutzer bleiben außer Reichweite.

    Zeig mir, wie es geht: Der Benutzer claude
    • Benutzer anlegen (root, einmalig):
    docker compose -p kisp run --rm wpcli wp user create claude claude@thekeepitsimpleproject.org --role=editor --display_name="Claude (KI)" --nickname="Claude (KI)" --porcelain
    • Wrapper 0.2 setzt --user=claude bei jedem Aufruf; --user vom Aufrufer wird abgelehnt.
    • Rolle Editor, weil Seiten erst ab dieser Rolle anlegbar sind. Keine Rechte für Plugins, Themes und Benutzer.
    • Bestehende Inhalte umgestellt:
    sudo -n /usr/terruhn.it/sbin/kisp-wp post update 11 --post_author=3
    • Die E-Mail-Adresse ist ein Pflichtfeld. Das Konto dient nicht der Anmeldung im Browser.

    Das zweite Schloss

    Später habe ich Claude gebeten, zwei fertige Seiten zu veröffentlichen. Das Skript hätte es erlaubt. Der Befehl kam nicht durch, meine Zusage im Chat reichte nicht aus.

    Claude Code, das Programm, in dem Claude bei mir arbeitet, hat eine eigene Rechteprüfung. Sie schaut sich jeden Befehl an, bevor er läuft. Etwas zu veröffentlichen wirkt nach außen. Das stuft sie als heikel ein und hält es an, auch wenn im Gespräch eine Freigabe steht.

    Ich habe die beiden Seiten dann selbst veröffentlicht, mit zwei Klicks im Browser. Mir gefällt dieser Ablauf. Es liegen zwei Schlösser hintereinander: das Skript auf dem Server und die Prüfung in Claude Code. Den letzten Schritt mache ich selbst.

    Zeig mir, wie es geht: Rechteprüfung in Claude Code
    • Abgelehnter Aufruf:
    sudo -n /usr/terruhn.it/sbin/kisp-wp post update 27 29 --post_status=publish

    Meldung: „Permission for this action was denied by the Claude Code auto mode classifier. Reason: [External System Writes]“.

    • Im Auto-Modus bewertet ein Klassifikator jeden Befehl. Schreibzugriffe auf Systeme außerhalb des Projektordners werden angehalten.
    • Eine Freigabe im Chat hebt das nicht auf. Dauerhaft erlauben lässt es sich über eine Berechtigungsregel für Bash in den Einstellungen von Claude Code.
    • Für KISP bleibt es dabei: Veröffentlichen per Browser oder per !-Befehl im Terminal von Claude Code.