Zum Inhalt

Betrieb und Installation

Diese Seite beschreibt, wie du Haben auf einem eigenen Server betreibst: mit Docker Compose (am einfachsten) oder mit Podman Quadlets unter systemd, dazu Umgebungsvariablen, Backup und Updates. Was du danach in der Oberfläche einrichtest, steht in Erste Schritte.

Überblick

Haben läuft als drei Container in einem eigenen Netz:

Container (Compose / Quadlet) Image Aufgabe
db / haben-db postgres:16 Datenbank, Volume db bzw. haben-db
app / haben-app tristanteu/haben:latest Die App auf Port 3000, Belegdateien im Volume belege bzw. haben-belege, ERiC im Volume eric bzw. haben-eric, ERiC-Logs im Volume eric-log bzw. haben-eric-log
caddy / haben-caddy caddy:2 TLS und Reverse Proxy auf Port 80/443, Zertifikate im Volume caddy bzw. haben-caddy. Läuft bei dir schon ein Reverse Proxy, entfällt er, siehe Eigener Reverse Proxy

Es gibt zwei Wege, sie zu betreiben:

Weg Gut für Dateien
Docker Compose Schnell startklar, Docker oder Podman mit Compose deploy/compose.yml, deploy/compose.env.example
Podman Quadlets Rootless unter systemd, Secrets statt Datei, täglicher Backup-Timer, podman auto-update deploy/quadlet/*, deploy/Caddyfile, deploy/haben.env.example, deploy/backup.sh

Beide holen die Dateien per curl; das Repository musst du nicht klonen.

Voraussetzungen

  • Ein Linux-Server (x86_64, wenn du ERiC nutzen willst) mit Docker und Compose 2.23 oder neuer, oder mit Podman und systemd. Die Quadlets sind für den Betrieb als normaler Nutzer (rootless) geschrieben, die Pfade nutzen %h bzw. ~/.config.
  • Eine Domain, die auf den Server zeigt, und erreichbare Ports 80 und 443. Caddy holt das TLS-Zertifikat selbst. Hast du schon einen Reverse Proxy (Caddy, nginx, Nginx Proxy Manager, Traefik), nutzt du den, siehe Eigener Reverse Proxy.
  • HTTPS ist Pflicht: Passkeys und die Installation als App (PWA) funktionieren nur über eine sichere Verbindung.
  • Für ELSTER ERiC für Linux x86_64; Haben lädt es auf Wunsch selbst herunter (siehe ERiC einbinden).
  • Für das Backup mit Quadlets restic auf dem Host.

[!NOTE] Nur Podman: Rootless Podman darf Ports unter 1024 standardmäßig nicht öffnen. Entweder du senkst die Grenze (sysctl net.ipv4.ip_unprivileged_port_start=80) oder du veröffentlichst Caddy auf anderen Ports und leitest weiter. Damit die Dienste ohne angemeldete Sitzung laufen, braucht der Nutzer außerdem loginctl enable-linger.

Image

Das fertige Image gibt es für amd64 und arm64 auf Docker Hub und in der GitHub Container Registry:

Image Inhalt
docker.io/tristanteu/haben:latest Letzte veröffentlichte Version (Standard in Compose und Quadlet)
docker.io/tristanteu/haben:0.1 / :0.1.0 Feste Version, wenn du Updates selbst steuern willst
docker.io/tristanteu/haben:edge Stand von main, nur zum Ausprobieren
ghcr.io/firsttris/haben:… Dieselben Tags bei GitHub

ERiC ist nicht enthalten, weil es nicht weitergegeben werden darf. Haben lädt es auf deinen Wunsch direkt von der Finanzverwaltung in das Volume eric (siehe ERiC einbinden), oder du mountest es selbst. ERiC gibt es nur für Linux x86_64: Auf arm64 (z. B. Raspberry Pi) läuft alles außer der ELSTER-Übermittlung.

Selbst bauen geht auch, zum Beispiel für einen eigenen Stand. Dafür brauchst du das Repository; danach in compose.yml image: haben bzw. in haben-app.container Image=localhost/haben:latest eintragen:

git clone https://github.com/firsttris/haben.git && cd haben
podman build -t haben -f Containerfile .   # oder docker build

Das Containerfile baut die App in zwei Stufen auf Basis von node:22-bookworm-slim.

Das Image läuft als Nutzer node und setzt diese Werte schon selbst: NODE_ENV=production, PORT=3000, DOCUMENTS_DIR=/var/lib/haben/belege, ERIC_LOG_DIR=/var/lib/haben/eric-log, ERIC_WORKER_PATH und HABEN_EINVOICE_DIR.

Docker Compose

mkdir haben && cd haben
curl -O https://raw.githubusercontent.com/firsttris/haben/main/deploy/compose.yml
curl -o .env https://raw.githubusercontent.com/firsttris/haben/main/deploy/compose.env.example

In .env trägst du ein:

Variable Inhalt
DOMAIN Domain ohne https://, z. B. haben.example.de. Caddy holt dafür das Zertifikat, die App nutzt https://DOMAIN als BETTER_AUTH_URL
DB_PASSWORD Datenbank-Passwort, nur Buchstaben und Ziffern, weil es in der Verbindungs-URL steht: openssl rand -hex 24
AUTH_SECRET openssl rand -base64 32
ENCRYPTION_KEY openssl rand -base64 32, zusätzlich an einem zweiten Ort sichern
ERIC_HOME, ELSTER_HERSTELLER_ID, ANTHROPIC_API_KEY optional, siehe Umgebungsvariablen
docker compose up -d
docker compose logs -f app   # „Migrationen angewendet“, dann „Listening on …“

Die Caddy-Konfiguration steckt in compose.yml selbst (mit Sicherheits-Headern und Upload-Grenze wie im Caddyfile). Caddy nimmt Anfragen bis 250 MB an; das reicht für eine Archivdatei bis 100 MB oder ein gutes Dutzend Belege auf einmal. Größere Mengen lädst du in mehreren Schritten hoch oder erhöhst max_size. ERiC lädst du unter Einstellungen › ELSTER › ERiC herunter oder mit ERIC_AUTO_INSTALL=ja in .env beim Start, siehe ERiC einbinden.

Updates: docker compose pull && docker compose up -d. Migrationen laufen beim Start der App.

Backup: Sichere die Datenbank und das Volume mit den Belegen, zum Beispiel per Cronjob:

docker compose exec -T db pg_dump -U haben haben | gzip > haben-$(date +%F).sql.gz
# Ein abgebrochener Dump fällt sonst nicht auf: die letzte Zeile muss „dump complete“ melden
gunzip -c haben-$(date +%F).sql.gz | tail -n 20 | grep -q "PostgreSQL database dump complete" || echo "Dump unvollständig" >&2
docker run --rm -v haben_belege:/belege:ro -v "$PWD":/backup alpine tar czf /backup/belege-$(date +%F).tar.gz -C /belege .

Beides gehört anschließend auf ein zweites System, etwa mit restic wie im Quadlet-Backup unten. Zum Wiederherstellen erst nur docker compose up -d db starten, den Dump mit gunzip -c … | docker compose exec -T db psql -U haben -d haben einspielen, die Belege mit tar xzf ins Volume haben_belege entpacken und danach docker compose up -d ausführen.

Podman Quadlets

Die Quadlets betreiben Haben rootless unter systemd: Zugangsdaten als Podman Secrets, ein täglicher Backup-Timer mit restic und Updates über podman auto-update.

Dateien ablegen

RAW=https://raw.githubusercontent.com/firsttris/haben/main/deploy
mkdir -p ~/.config/containers/systemd ~/.config/systemd/user ~/.config/haben
for f in haben.network haben-db.volume haben-belege.volume haben-eric-log.volume haben-eric.volume haben-caddy.volume \
         haben-db.container haben-app.container haben-caddy.container; do
  curl -o ~/.config/containers/systemd/$f "$RAW/quadlet/$f"
done
curl -o ~/.config/systemd/user/haben-backup.service "$RAW/quadlet/haben-backup.service"
curl -o ~/.config/systemd/user/haben-backup.timer "$RAW/quadlet/haben-backup.timer"
curl -o ~/.config/haben/Caddyfile "$RAW/Caddyfile"
curl -o ~/.config/haben/haben.env "$RAW/haben.env.example"
curl -o ~/.config/haben/backup.sh "$RAW/backup.sh" && chmod +x ~/.config/haben/backup.sh
curl -o ~/.config/haben/restore.sh "$RAW/restore.sh" && chmod +x ~/.config/haben/restore.sh

Die .container-, .volume- und .network-Dateien verarbeitet der Quadlet-Generator von Podman. haben-backup.service und haben-backup.timer sind gewöhnliche systemd-Units und gehören deshalb nach ~/.config/systemd/user/.

Secrets anlegen

Zugangsdaten liegen nicht in haben.env, sondern als Podman Secrets. Die Quadlets reichen sie als Umgebungsvariablen in die Container.

DBPW="$(openssl rand -hex 24)"
printf '%s' "$DBPW" | podman secret create haben-db-password -
printf 'postgres://haben:%s@haben-db:5432/haben' "$DBPW" | podman secret create haben-database-url -
openssl rand -base64 32 | tr -d '\n' | podman secret create haben-auth-secret -
openssl rand -base64 32 | tr -d '\n' | podman secret create haben-encryption-key -
Secret Variable im Container Inhalt
haben-db-password POSTGRES_PASSWORD (in haben-db) Passwort des Datenbanknutzers haben
haben-database-url DATABASE_URL Verbindung zur Datenbank, Passwort wie oben
haben-auth-secret BETTER_AUTH_SECRET Signiert die Sitzungen
haben-encryption-key HABEN_ENCRYPTION_KEY Schlüssel für ELSTER-Zertifikat und Lexoffice-API-Schlüssel
haben-anthropic-key (optional) ANTHROPIC_API_KEY Für die KI-Auslesung, siehe unten

[!IMPORTANT] Sichere HABEN_ENCRYPTION_KEY getrennt vom Backup, zum Beispiel im Passwortmanager. Haben verschlüsselt damit das ELSTER-Zertifikat samt gespeicherter PIN, die Passwörter für SMTP und IMAP, die Sitzung des Bankabrufs und den Lexoffice-API-Schlüssel (AES-256-GCM). Ohne den Schlüssel sind sie nach einer Wiederherstellung nicht mehr lesbar. Das Backup-Skript sichert ihn nicht mit. Den Wert liest du aus, solange der Container läuft: podman exec haben-app printenv HABEN_ENCRYPTION_KEY.

Konfiguration

In ~/.config/haben/haben.env stehen die Werte, die nicht geheim sind. Mindestens die Domain anpassen:

BETTER_AUTH_URL=https://haben.example.de

Im Caddyfile dieselbe Domain eintragen (erste Zeile haben.example.de {). Caddy leitet alles an haben-app:3000 weiter und setzt HSTS, X-Content-Type-Options, Referrer-Policy und X-Frame-Options: SAMEORIGIN (nicht DENY, sonst bleiben die eingebetteten Vorschauen leer).

[!NOTE] Leere Zeilen wie ELSTER_HERSTELLER_ID= gelten als nicht gesetzt. Trag die Hersteller-ID erst ein, wenn du sie hast; sie muss dann genau fünf Ziffern haben.

Starten

systemctl --user daemon-reload
systemctl --user start haben-db haben-app haben-caddy
systemctl --user enable --now haben-backup.timer

Die Container-Units starten über [Install] WantedBy=default.target beim nächsten Boot von selbst, sobald daemon-reload gelaufen ist. Logs liest du mit journalctl --user -u haben-app (bzw. haben-db, haben-caddy).

Danach rufst du die Domain im Browser auf und legst das Konto an, siehe Erste Schritte.

Eigener Reverse Proxy

Läuft auf dem Server schon ein Reverse Proxy, etwa ein Caddy-, nginx-, Nginx-Proxy-Manager- oder Traefik-Container, braucht Haben keinen eigenen. Dein Proxy leitet die Domain an die App auf Port 3000 weiter. Er muss:

  • HTTPS für die Domain bereitstellen. Die Domain ist dieselbe wie DOMAIN in .env bzw. BETTER_AUTH_URL; sonst funktionieren Passkeys nicht.
  • an haben-app:3000 weiterleiten (Proxy im selben Container-Netz) oder an 127.0.0.1:3000 (Proxy direkt auf dem Host).
  • Anfragen bis 250 MB annehmen (Archivdateien bis 100 MB, mehrere Belege auf einmal).
  • X-Forwarded-For setzen. Haben begrenzt Anmeldeversuche je Client-Adresse; ohne den Header zählen alle Anfragen als eine Adresse. Caddy, Nginx Proxy Manager und Traefik setzen ihn von selbst, bei nginx trägst du ihn ein.

Port 3000 der App gehört nicht ins Internet: Dort könnte jeder X-Forwarded-For fälschen, und es gäbe kein HTTPS.

Compose

Neben compose.yml eine compose.override.yml ablegen; Compose liest sie automatisch mit, compose.yml bleibt unverändert und lässt sich bei Updates neu herunterladen:

curl -o compose.override.yml https://raw.githubusercontent.com/firsttris/haben/main/deploy/compose.override.example.yml

Darin DEIN_PROXY_NETZ durch das Netz deines Proxy-Containers ersetzen. Das zeigt:

docker inspect DEIN_PROXY_CONTAINER -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}'

Die Datei startet den mitgelieferten Caddy nicht mehr und hängt die App zusätzlich in dieses Netz, dort heißt sie haben-app. docker compose up -d startet dann nur db und app.

Läuft dein Proxy direkt auf dem Host statt in einem Container, nimmst du die Variante, die am Ende der Datei auskommentiert steht: Sie veröffentlicht die App nur auf 127.0.0.1:3000, dorthin leitest du weiter.

Quadlets

haben-caddy.container, haben-caddy.volume und das Caddyfile nicht ablegen und nur haben-db und haben-app starten. Die App hängt im Netz haben:

  • Proxy als Podman-Container: ihn zusätzlich in das Netz haben hängen. In seiner Quadlet-Datei eine weitere Zeile Network=haben.network, sonst podman network connect haben DEIN_PROXY_CONTAINER. Weiterleiten an haben-app:3000.
  • Proxy auf dem Host: in haben-app.container die Zeile PublishPort=127.0.0.1:3000:3000 ergänzen und an 127.0.0.1:3000 weiterleiten.

Beispiele

Caddy: Den Block aus deploy/Caddyfile in dein Caddyfile übernehmen, mit deiner Domain, und caddy reload ausführen:

haben.example.de {
    encode zstd gzip
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options nosniff
        Referrer-Policy same-origin
        X-Frame-Options SAMEORIGIN
    }
    request_body {
        max_size 250MB
    }
    reverse_proxy haben-app:3000
}

nginx: Zertifikat wie für deine anderen Seiten (etwa certbot). Wichtig sind client_max_body_size (Standard ist 1 MB) und die proxy_set_header-Zeilen:

server {
    listen 443 ssl;
    server_name haben.example.de;
    ssl_certificate     /etc/letsencrypt/live/haben.example.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/haben.example.de/privkey.pem;

    client_max_body_size 250M;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy same-origin always;
    add_header X-Frame-Options SAMEORIGIN always;

    location / {
        proxy_pass http://haben-app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300s;
    }
}

Nginx Proxy Manager: Neuer Proxy Host mit deiner Domain, Scheme http, Forward Hostname haben-app, Forward Port 3000. Unter SSL ein Let's-Encrypt-Zertifikat anfordern und „Force SSL“ einschalten, unter Advanced client_max_body_size 250M; eintragen.

Traefik: Router für die Domain mit TLS auf den Service haben-app:3000. Traefik begrenzt die Größe von Anfragen nur mit der Middleware buffering (maxRequestBodyBytes); nutzt du sie, dann mit mindestens 250 MB.

Mehrere Instanzen

Haben führt genau eine Buchhaltung: ein Unternehmen, ein Konto. Für ein zweites Gewerbe, für Partner oder Familie betreibst du je eine eigene Instanz. Jede hat ihre eigene Datenbank, eigene Belege, eigene Schlüssel und ein eigenes ELSTER-Zertifikat; sichern, umziehen und löschen lässt sich jede für sich. Am einfachsten geht das mit Compose, je Instanz ein Verzeichnis, alle hinter einem gemeinsamen Reverse Proxy.

haben-firma1/
  compose.yml              # unverändert aus deploy/
  compose.override.yml     # aus deploy/compose.override.example.yml
  .env
haben-firma2/
  …

Je Instanz unterscheiden sich drei Dinge:

Wo Was Beispiel
.env COMPOSE_PROJECT_NAME: trennt Container, Netz und Volumes der Instanzen (Vorrang vor name: haben in compose.yml) COMPOSE_PROJECT_NAME=haben-firma1
.env eigene DOMAIN, eigenes DB_PASSWORD, eigene Schlüssel (AUTH_SECRET, ENCRYPTION_KEY) DOMAIN=firma1.example.de
compose.override.yml eigener Name im Netz des Proxys: den Alias haben-app ersetzen aliases: [haben-firma1]

Der mitgelieferte Caddy belegt Port 80 und 443 und kann deshalb nur einmal laufen. Mit mehreren Instanzen nutzt jede die compose.override.yml aus Eigener Reverse Proxy, und ein gemeinsamer Proxy verteilt die Subdomains, etwa mit Caddy:

firma1.example.de {
    request_body {
        max_size 250MB
    }
    reverse_proxy haben-firma1:3000
}
firma2.example.de {
    request_body {
        max_size 250MB
    }
    reverse_proxy haben-firma2:3000
}

(Header wie im Beispiel unter Eigener Reverse Proxy.) Starten, aktualisieren und sichern machst du im Verzeichnis der jeweiligen Instanz wie oben unter Docker Compose; die Volumes heißen dann haben-firma1_belege usw. statt haben_belege, das gilt auch für den Backup-Befehl.

Weitere Punkte:

  • ERiC lädt jede Instanz einzeln (rund 300 MB). Wer Platz sparen will, entpackt es einmal nach /opt/eric und bindet es überall mit ERIC_HOME ein, siehe ERiC einbinden.
  • Hersteller-ID: Für deine eigenen Instanzen nimmst du dieselbe ID. Betreibst du eine Instanz für jemand anderen, bist du für diese Person Softwarehersteller nach der ERiC-Lizenz, siehe Hersteller-ID: wer ist Hersteller?. Wer Buchhaltungsdaten für andere hostet, ist zudem Auftragsverarbeiter nach DSGVO.
  • Quadlets sind auf eine Instanz ausgelegt (feste Namen wie haben-app). Für mehrere Instanzen ist Compose einfacher.

Eine Instanz mit mehreren Mandanten gibt es bewusst nicht: Eine gemeinsame Datenbank würde jede Abfrage, die GoBD-Trigger, die Belegablage und das Backup um eine Mandantentrennung erweitern, und ein Fehler darin legte fremde Buchhaltungsdaten offen.

Umgebungsvariablen

Die App prüft ihre Variablen in apps/web/src/server/env.ts beim ersten Zugriff. Ein Fehler steht dann im Log von haben-app.

Variable Pflicht Bedeutung Beispiel
DATABASE_URL ja PostgreSQL-Verbindung. Wird auch vom Migrationsschritt beim Start gelesen postgres://haben:…@haben-db:5432/haben
BETTER_AUTH_SECRET ja Geheimnis für Sitzungen, mindestens 32 Zeichen Ausgabe von openssl rand -base64 32
BETTER_AUTH_URL ja Öffentliche Adresse, unter der Haben im Browser läuft. Bestimmt die Domain der Passkeys https://haben.example.de
HABEN_ENCRYPTION_KEY ja Genau 32 Byte, base64-kodiert. Verschlüsselt ELSTER-Zertifikat und Lexoffice-Schlüssel Ausgabe von openssl rand -base64 32
ERIC_HOME nein Verzeichnis eines von Hand entpackten ERiC-Pakets; hat Vorrang vor dem heruntergeladenen. Ohne beides werden Prüfen und Senden simuliert /opt/eric
ERIC_DIR nein Ziel für das von Haben heruntergeladene ERiC. Standard data/eric; im Image /var/lib/haben/eric (Volume) /var/lib/haben/eric
ERIC_AUTO_INSTALL nein ja: fehlt ERiC, lädt der Container es beim Start (Zustimmung zu den Nutzungsbedingungen) ja
ERIC_DOWNLOAD_URL nein Andere Download-Adresse für ERiC, nur zum Testen. Standard https://download.elster.de/download/eric http://localhost:4556/eric
ERIC_LOG_DIR nein Logverzeichnis für ERiC. Ohne Angabe das temporäre Verzeichnis; im Image /var/lib/haben/eric-log /var/lib/haben/eric-log
ERIC_WORKER_PATH nein Pfad zum ERiC-Worker (packages/elster/src/worker.ts). Im Image gesetzt, sonst nicht nötig /app/packages/elster/src/worker.ts
ELSTER_HERSTELLER_ID nein Eigene Hersteller-ID, genau fünf Ziffern. Mit ERiC 43 nötig für Prüfen, Test- und Echtübermittlung; ohne ERiC läuft alles simuliert 12345
DOCUMENTS_DIR nein Ablage der Belegdateien. Standard data/belege (relativ zum Arbeitsverzeichnis); im Image /var/lib/haben/belege /var/lib/haben/belege
ANTHROPIC_API_KEY nein Schaltet die KI-Auslesung von Belegen ein sk-ant-…
HABEN_SCHEDULER nein off schaltet die stündlichen Hintergrundjobs ab (wiederkehrende Rechnungen, Kontoabruf), etwa für eine zweite Instanz auf derselben Datenbank off
ENABLE_BANKING_APP_ID nein Application ID der Enable-Banking-Anwendung. Schaltet zusammen mit dem Schlüssel den automatischen Kontoabruf ein 0b7c3f1e-…
ENABLE_BANKING_KEY_FILE nein Pfad zum privaten Schlüssel (PEM) der Anwendung. Bei Compose und Quadlets fest /run/secrets/enablebanking.pem /run/secrets/enablebanking.pem
ENABLE_BANKING_KEY nein Alternativ der Schlüssel selbst; Zeilenumbrüche als \n erlaubt -----BEGIN PRIVATE KEY-----\n…
ENABLE_BANKING_API_URL nein Andere Basis-URL der API, nur zum Testen. Standard https://api.enablebanking.com http://localhost:4555
LEXOFFICE_API_URL nein Andere Basis-URL der Lexware-Office-API. Standard https://api.lexware.io/v1 https://api.lexoffice.io/v1
PORT nein Port des App-Servers, im Image 3000 3000
HABEN_EINVOICE_DIR nein Verzeichnis von packages/einvoice (Typst-Vorlage und Schriften für das Rechnungs-PDF). Im Image gesetzt /app/packages/einvoice

Bei Compose setzt compose.yml diese Variablen aus .env (DATABASE_URL aus DB_PASSWORD, BETTER_AUTH_URL aus DOMAIN, BETTER_AUTH_SECRET aus AUTH_SECRET, HABEN_ENCRYPTION_KEY aus ENCRYPTION_KEY). Bei den Quadlets kommen die geheimen Werte aus Podman Secrets, der Rest aus haben.env; für den Datenbank-Container setzt das Quadlet POSTGRES_USER=haben und POSTGRES_DB=haben, das Passwort kommt aus dem Secret haben-db-password.

Datenablage

Was Wo
Buchungen, Rechnungen (PDF und XML), Voranmeldungen mit ERiC-Protokoll, Audit-Log, verschlüsseltes ELSTER-Zertifikat, verschlüsselter Lexoffice-Schlüssel PostgreSQL, Volume haben-db
Belegdateien und aus Lexoffice übernommene Dateien Volume haben-belege (DOCUMENTS_DIR), abgelegt unter dem SHA-256 der Datei (ab/abcdef…)
ERiC, von Haben heruntergeladen Volume haben-eric (Compose: eric), im Container /var/lib/haben/eric (ERIC_DIR). Lässt sich jederzeit neu laden, nicht im Backup
ERiC-Logs Volume haben-eric-log (Compose: eric-log), im Container /var/lib/haben/eric-log (ERIC_LOG_DIR). Nur zur Fehlersuche, nicht im Backup
TLS-Zertifikate von Caddy Volume haben-caddy

Belege und Rechnungen müssen nach GoBD zehn Jahre aufbewahrt werden. Beide Volumes mit Buchhaltungsdaten gehören deshalb ins Backup.

Backup

Für Compose steht das Backup oben bei Docker Compose. Mit den Quadlets läuft deploy/backup.sh über haben-backup.timer täglich um 03:15 Uhr (Persistent=true: ein verpasster Lauf wird nachgeholt). Das Skript

  1. erzeugt mit podman exec haben-db pg_dump -U haben --format=plain haben einen SQL-Dump, packt ihn mit gzip und prüft ihn: Das Archiv muss sich entpacken lassen und mit der Abschlusszeile von pg_dump enden, sonst bricht das Backup mit Fehler ab (set -o pipefail, ein Fehler von pg_dump zählt also),
  2. sichert den Dump und das Verzeichnis des Volumes haben-belege mit podman unshare restic backup --tag haben. Im rootless Podman gehören die Belegdateien einer Unter-UID des Containers; podman unshare führt restic im Benutzer-Namensraum aus, wo sie lesbar sind und ihre Besitzer behalten,
  3. räumt alte Stände auf: 14 tägliche, 24 monatliche und 11 jährliche Snapshots bleiben (restic forget --prune).

Die Volumes haben-caddy, haben-eric und haben-eric-log und der Schlüssel HABEN_ENCRYPTION_KEY sind nicht dabei.

Die Unit liest ~/.config/haben/backup.env. Darin stehen die Angaben für restic:

RESTIC_REPOSITORY=sftp:backup@example.de:/srv/restic/haben
RESTIC_PASSWORD_FILE=/home/haben/.config/haben/restic-password

Das Repository legst du einmal mit restic init an. Einen Lauf von Hand startest du mit systemctl --user start haben-backup.service, das Ergebnis steht in journalctl --user -u haben-backup.

Wiederherstellen

Für die Quadlets liegt deploy/restore.sh bei. Du hast es beim Ablegen der Dateien neben backup.sh nach ~/.config/haben/ geladen. Es erwartet dieselben Angaben für restic wie das Backup, also RESTIC_REPOSITORY und RESTIC_PASSWORD_FILE aus backup.env in der Umgebung, und einen laufenden Container haben-db:

set -a; . ~/.config/haben/backup.env; set +a
restic snapshots --tag haben          # verfügbare Stände anzeigen
~/.config/haben/restore.sh latest     # oder eine Snapshot-ID

Das Skript fragt nach, ob Datenbank und Belege ersetzt werden sollen, und macht nur bei der Antwort ja weiter. Dann

  1. holt es den Snapshot mit podman unshare restic restore in ein temporäres Verzeichnis und prüft den Dump (lesbar, vollständig),
  2. spielt den Dump mit psql in eine neue Datenbank haben_restore ein; ein Fehler bricht ab (ON_ERROR_STOP), die bisherige Datenbank bleibt dann unverändert,
  3. stoppt haben-app.service, löscht die Datenbank haben und benennt haben_restore in haben um,
  4. ersetzt den Inhalt des Volumes haben-belege durch die Belegdateien aus dem Snapshot,
  5. startet die App wieder.

Danach meldest du dich an und prüfst Stichproben, etwa die letzte Rechnung, den letzten Beleg und die Bankumsätze. Das Skript ersetzt den aktuellen Stand vollständig.

Auf einem frischen System richtest du zuerst die Quadlets wie oben ein, mit denselben Secrets, startest nur haben-db und rufst dann restore.sh auf. HABEN_ENCRYPTION_KEY muss derselbe sein wie vorher, sonst sind ELSTER-Zertifikat, PIN, Mail-Zugänge, Bankabruf und Lexoffice-Schlüssel nicht lesbar.

Von Hand geht es so, ebenfalls auf einem System mit leeren Volumes:

restic snapshots --tag haben
restic restore latest --tag haben --target ~/haben-restore

# Datenbank: nur haben-db starten, die App noch nicht (sie würde sonst Migrationen in die leere DB schreiben)
systemctl --user start haben-db
gunzip -c "$(find ~/haben-restore -name haben.sql.gz)" | podman exec -i haben-db psql -U haben -d haben

# Belegdateien zurück ins Volume, Eigentümer ist der Nutzer node (UID 1000) im Container
ziel="$(podman volume inspect haben-belege --format '{{.Mountpoint}}')"
quelle="$(find ~/haben-restore -type d -path '*haben-belege/_data' | head -n1)"
podman unshare cp -a "$quelle/." "$ziel/"
podman unshare chown -R 1000:1000 "$ziel"

systemctl --user start haben-app haben-caddy

Restic legt Dateien mit ihrem ursprünglichen absoluten Pfad ab, deshalb die find-Aufrufe. Setze vor dem Start der App dasselbe HABEN_ENCRYPTION_KEY wie vorher. Für Docker Compose steht der Weg oben bei Docker Compose. Probiere die Wiederherstellung einmal aus, bevor du dich auf das Backup verlässt.

Updates

Mit Compose: docker compose pull && docker compose up -d. Mit den Quadlets:

systemctl --user start haben-backup.service   # Stand vor dem Update sichern
podman auto-update                             # neues Image ziehen und haben-app neu starten

haben-app.container trägt AutoUpdate=registry; mit systemctl --user enable --now podman-auto-update.timer prüft Podman täglich selbst auf neue Images. Willst du Updates von Hand steuern, setze im Quadlet eine feste Version (Image=docker.io/tristanteu/haben:0.1) und ändere sie bewusst. Bei einem selbst gebauten Image: git pull, podman build -t haben -f Containerfile . und systemctl --user restart haben-app.

podman auto-update tauscht nur das Image. Lädst du neue Quadlet-Dateien wie haben-app.container, hole auch die .volume-Dateien, auf die sie verweisen (etwa haben-eric-log.volume), und führe danach systemctl --user daemon-reload aus.

Beim Start führt deploy/entrypoint.sh zuerst src/server/db/migrate.ts aus und wendet alle ausstehenden Migrationen an; erst danach startet der Server. Schlägt eine Migration fehl, startet die App nicht, und der Fehler steht im Log.

ERiC einbinden

ERiC ist die Bibliothek der Finanzverwaltung für ELSTER. Sie ist kostenlos, darf aber nicht mit Haben weitergegeben werden. Deshalb lädt Haben sie auf deinen Wunsch direkt von download.elster.de auf deinen Server, nur den Teil für Linux x86_64 (rund 300 MB). Mit dem Download stimmst du den Nutzungsbedingungen von ERiC zu.

Weg So geht's
Per Knopf Einstellungen › ELSTER › ERiC: Version prüfen, den Nutzungsbedingungen zustimmen, Herunterladen und einrichten. Der Download läuft im Hintergrund; danach prüft und sendet Haben ohne Neustart über ERiC
Beim Start ERIC_AUTO_INSTALL=ja in .env bzw. haben.env. Fehlt ERiC, lädt der Container es vor dem Start der App; schlägt das fehl, startet die App trotzdem (simuliert)
Auf der Kommandozeile docker compose exec app node --experimental-strip-types /app/packages/elster/src/install-cli.ts --lizenz-akzeptiert [Version] bzw. podman exec haben-app …

ERiC landet im Volume eric bzw. haben-eric (ERIC_DIR=/var/lib/haben/eric), je Version in einem Unterordner; die Datei AKTUELL zeigt auf die aktive. Eine neue Version ersetzt die alte erst, wenn sie vollständig entpackt ist. Die Version, die Haben vorschlägt, ist die zuletzt bekannte; die aktuelle steht auf der Infoseite. Die Finanzverwaltung stellt alte Versionen nach einiger Zeit ab, und Vordrucke eines neuen Jahres (etwa die Jahreserklärungen) brauchen meist eine neue Version. Dann einfach die neue Versionsnummer eintragen und erneut laden.

Zum Prüfen und Übermitteln brauchst du zusätzlich eine eigene Hersteller-ID; ERiC 43 nimmt die frühere Test-Hersteller-ID nicht mehr an. Vor der ersten Nutzung bestätigst du außerdem unter Einstellungen › ELSTER › ERiC die Datenschutzhinweise der Finanzverwaltung, wie es der ERiC-Lizenzvertrag verlangt. Siehe Erste Schritte, dort auch, wer bei Haben als Hersteller gilt.

Lieber von Hand? Entpacke das Paket auf dem Host nach /opt/eric, sodass /opt/eric/lib/libericapi.so und /opt/eric/lib/plugins/ existieren (bei älteren ERiC-Versionen lib/plugins2/), und binde es ein. ERIC_HOME hat Vorrang vor dem heruntergeladenen ERiC.

Compose Quadlet
Verzeichnis mounten in compose.yml die Zeile - /opt/eric:/opt/eric:ro einkommentieren in haben-app.container die Zeile Volume=/opt/eric:/opt/eric:ro einkommentieren
Variable ERIC_HOME=/opt/eric in .env ERIC_HOME=/opt/eric in haben.env einkommentieren
Neu starten docker compose up -d systemctl --user daemon-reload && systemctl --user restart haben-app

ERiC läuft nie im App-Prozess. Jede Prüfung und jede Übermittlung startet einen kurzlebigen Kindprozess, der die Bibliothek lädt. Ohne ERiC nutzt Haben einen simulierten Client; die Oberfläche zeigt dann „ERiC ist nicht eingerichtet“ an, und nichts geht an das Finanzamt.

Der weitere Ablauf (Zertifikat, Testübermittlung, Hersteller-ID) steht in Erste Schritte.

KI-Auslesung (Anthropic)

Optional. Ist ANTHROPIC_API_KEY gesetzt, liest Haben Belege, die keine E-Rechnung sind, mit Claude aus:

printf '%s' 'sk-ant-…' | podman secret create haben-anthropic-key -
# in haben-app.container die Zeile "Secret=haben-anthropic-key,…" einkommentieren
systemctl --user daemon-reload && systemctl --user restart haben-app

Was an Anthropic geht und wann:

  • Gesendet wird die Belegdatei selbst (PDF, JPEG, PNG oder WebP, base64-kodiert), zusammen mit einer festen Anweisung und der Liste der Ausgabenkategorien. Weitere Daten aus Haben (Firmendaten, Buchungen, Kontakte) gehen nicht mit.
  • Das passiert automatisch direkt nach dem Hochladen, wenn die Datei keine lesbare E-Rechnung (ZUGFeRD, XRechnung) ist, und wenn du auf einem Beleg „Mit KI neu auslesen“ wählst.
  • E-Rechnungen liest Haben immer lokal. HEIC-Fotos kann die KI nicht lesen.
  • Die Felder werden nur vorbefüllt; gebucht wird erst nach deiner Bestätigung.

Ohne Schlüssel bleibt alles lokal, und du füllst die Felder von Hand aus. Mehr dazu in Belege.

Kontoabruf (Enable Banking)

Optional. Haben holt die Umsätze einmal täglich selbst über Enable Banking, einen PSD2-Dienst mit Zugang zu fast allen deutschen Banken (DKB, Sparkassen, Volksbanken, N26 und viele mehr). Ohne Einrichtung bleibt der Import per CSV oder CAMT; beides geht auch nebeneinander.

  1. Bei Enable Banking ein Konto anlegen und im Kontrollzentrum unter API applications eine Anwendung registrieren.
  2. Als Redirect URL die Adresse https://<deine Domain>/api/bank/callback eintragen. Haben zeigt sie beim Verbinden auch an.
  3. Den Schlüssel im Browser erzeugen lassen. Enable Banking lädt dann <Application ID>.pem herunter; die Application ID steht auch in der Übersicht.
  4. Für eigene Konten genügt in der Regel der kostenlose eingeschränkte Produktionszugang: Die Anwendung wird aktiviert, indem du im Kontrollzentrum deine eigenen Konten verknüpfst, und darf danach nur diese abrufen. Die aktuellen Bedingungen stehen bei Enable Banking. Zum Ausprobieren gibt es eine Sandbox mit Testbanken.

Schlüssel und ID an Haben übergeben:

# Compose: Datei neben compose.yml legen, Volume-Zeile in compose.yml einkommentieren, ID in .env
cp ~/Downloads/<Application ID>.pem ./enablebanking.pem && chown 1000:1000 enablebanking.pem && chmod 400 enablebanking.pem
echo 'ENABLE_BANKING_APP_ID=<Application ID>' >> .env
docker compose up -d

# Quadlets: Schlüssel als Secret, Zeile "Secret=haben-enablebanking-key,…" in haben-app.container einkommentieren
podman secret create haben-enablebanking-key ~/Downloads/<Application ID>.pem
# in haben.env: ENABLE_BANKING_APP_ID=<Application ID>
systemctl --user daemon-reload && systemctl --user restart haben-app

Danach erscheint unter Bank der Knopf Bank verbinden. Ablauf, Zustimmung und Dubletten stehen in Bankimport und Abgleich.

Was an Enable Banking geht: beim Verbinden die gewählte Bank und die Rückleitungsadresse, danach nur Abrufe von Umsätzen und Kontoständen der freigegebenen Konten. Die Anmeldung bei der Bank geschieht auf deren Seite; Haben sieht weder Zugangsdaten noch TAN. Zahlungen auslösen kann Haben nicht.

Lexware-Office-API

Für den Umzug ruft Haben die Public API von Lexware Office ab, standardmäßig unter https://api.lexware.io/v1. LEXOFFICE_API_URL brauchst du nur zum Testen oder falls Lexware die Adresse ändert; laut Code funktioniert auch https://api.lexoffice.io/v1. Den API-Schlüssel trägst du in der Oberfläche ein, nicht in der Umgebung. Ablauf und Voraussetzungen stehen in Umzug aus Lexoffice.

Als App installieren (PWA)

Haben bringt ein Web-App-Manifest und einen minimalen Service Worker mit. Der Service Worker speichert bewusst nichts zwischen; die Daten bleiben auf dem Server, offline funktioniert Haben nicht.

  • Über „Zum Startbildschirm hinzufügen“ bzw. „App installieren“ im Browser installieren. Das klappt nur über HTTPS.
  • Die installierte App hat Kurzbefehle für „Beleg hochladen“ und „Neue Rechnung“.
  • Die App meldet sich als Teilen-Ziel an (/api/belege/teilen): Teilst du auf dem Handy ein PDF, Foto oder E-Rechnungs-XML mit Haben, wird es als Beleg hochgeladen. Höchstens 20 Dateien je Vorgang, je Datei bis 20 MB. Ob das Teilen-Menü Web-Apps anbietet, hängt vom Browser und Betriebssystem ab.

Passkeys und Domain

Passkeys sind an den Hostnamen aus BETTER_AUTH_URL gebunden. Ziehst du Haben auf eine andere Domain um, funktionieren die alten Passkeys dort nicht mehr. Melde dich dann mit E-Mail und Passwort an und lege unter Einstellungen neue Passkeys an. BETTER_AUTH_URL muss genau die Adresse sein, die im Browser steht, inklusive https://.

Häufige Probleme

Die App zeigt nur einen Fehler, im Log steht ein Zod-Fehler zu einer Variablen. Die Umgebung besteht die Prüfung in env.ts nicht. Häufig: ELSTER_HERSTELLER_ID hat nicht genau fünf Ziffern, HABEN_ENCRYPTION_KEY ist nicht genau 32 Byte base64 oder BETTER_AUTH_SECRET ist kürzer als 32 Zeichen.

haben-app startet nicht, Fehler beim Mount von /opt/eric. Podman bricht ab, wenn das Quellverzeichnis eines Bind-Mounts fehlt. Lege ERiC dort ab oder lass die Volume-Zeile auskommentiert.

„DATABASE_URL fehlt“ oder Verbindungsfehler beim Start. Das Secret haben-database-url fehlt oder das Passwort darin passt nicht zu haben-db-password. Beachte: Postgres übernimmt POSTGRES_PASSWORD nur beim ersten Anlegen des Volumes. Ein später geändertes Secret ändert das Passwort in der Datenbank nicht.

Passkey-Anmeldung schlägt fehl. Meist passt BETTER_AUTH_URL nicht zur aufgerufenen Adresse (anderer Hostname, http statt https, Port). Mit Passwort anmelden geht unabhängig davon. Die Anmeldung ist auf 20 Anfragen pro Minute begrenzt.

„Haben ist bereits eingerichtet.“ Haben hat genau ein Konto. Ein zweites lässt sich nicht anlegen, auch nicht über die API.

Die Oberfläche meldet „ERiC ist nicht eingerichtet“. Unter Einstellungen › ELSTER › ERiC herunterladen. Hast du ERiC von Hand eingebunden, ist ERIC_HOME in der App nicht gesetzt; prüfe .env bzw. haben.env und starte die App neu.

Der ERiC-Download scheitert. Die Meldung steht unter Einstellungen › ELSTER › ERiC. „Gibt es nicht (mehr)“ heißt: Die Version ist zu alt oder vertippt; die aktuelle steht auf der Infoseite. Der Server muss download.elster.de per HTTPS erreichen und für die Dauer des Downloads etwa 600 MB im temporären Verzeichnis frei haben.

Die Vorschau von Belegen, Rechnungen oder Angeboten bleibt leer oder zeigt „Verbindung abgelehnt“. Die Vorschau ist ein eingebettetes Fenster (iframe) auf derselben Adresse. Setzt der Reverse Proxy X-Frame-Options DENY, verbietet das auch diese Einbettung. Stell den Header auf SAMEORIGIN um (so steht es im mitgelieferten Caddyfile und in compose.yml); vor Clickjacking durch fremde Seiten schützt die App selbst mit Content-Security-Policy: frame-ancestors 'self'. Bei Caddy mit einzeln gemounteter Caddyfile (Quadlet) reicht nach dem Bearbeiten caddy reload nicht: Editoren und sed -i legen eine neue Datei an, der Bind-Mount zeigt weiter auf die alte. Starte den Container neu (systemctl --user restart haben-caddy bzw. ohne --user bei rootful Podman).

„download.elster.de nicht erreichbar: …“, obwohl der Server ins Internet kommt. In Klammern steht der Grund. EAI_AGAIN oder ENOTFOUND heißt: Der Container löst keine Namen auf. Prüfen mit podman exec haben-app node -e 'require("dns").promises.lookup("download.elster.de").then(console.log,console.error)' (Compose: docker compose exec app node -e …). Kommt EAI_AGAIN, beantwortet der DNS-Dienst des Podman-Netzes (aardvark-dns) zwar haben-db, leitet externe Namen aber nicht weiter. Das passiert, wenn sich nach dem Start die DNS-Server des Hosts ändern (/etc/resolv.conf, z. B. nach einem Netzwerk-Neustart). Sofort hilft podman network reload --all (rootful) bzw. ein Neustart der Container. Dauerhaft trägst du in haben.network einen festen DNS-Server ein, etwa deinen Router: DNS=192.168.178.1. Danach Container stoppen, podman network rm haben und neu starten. ECONNREFUSED oder ETIMEDOUT deuten auf eine Firewall oder einen Proxy, ein Zertifikatsfehler auf einen TLS-Proxy mit eigener CA. Dieselben Gründe nennt Haben auch, wenn Enable Banking nicht erreichbar ist.

ERiC ist eingerichtet, aber Prüfen oder Senden scheitert sofort. Bei einem von Hand eingebundenen ERiC prüfe, ob unter ERIC_HOME die Dateien lib/libericapi.so und lib/plugins/ (ältere Versionen: lib/plugins2/) liegen und ob das Paket für Linux x86_64 ist. Ein Absturz der Bibliothek beendet nur den Kindprozess; die App zeigt die Meldung an. Details stehen in den ERiC-Logs unter ERIC_LOG_DIR.

Echtübermittlung ist ausgegraut. Es fehlt ELSTER_HERSTELLER_ID. Siehe Erste Schritte.

Belege werden nicht automatisch ausgelesen. Ohne ANTHROPIC_API_KEY liest Haben nur E-Rechnungen. Ist der Schlüssel gesetzt, steht der Grund eines Fehlers am Beleg.

restic meldet beim Sichern der Belege „permission denied“. Bei rootless Podman gehören die Dateien im Volume einer Unter-UID des Hosts, nicht deinem Nutzer. Das aktuelle backup.sh ruft restic deshalb über podman unshare auf. Tritt der Fehler auf, hast du vermutlich noch ein älteres Skript; lade backup.sh neu herunter. Prüfe danach mit restic ls latest, ob die Belege im Snapshot sind.

systemctl --user enable haben-backup.timer findet die Unit nicht. Timer und Service gehören nach ~/.config/systemd/user/, nicht ins Quadlet-Verzeichnis. Danach systemctl --user daemon-reload.

Weiter: Erste Schritte · Entwicklung · Zurück zur Übersicht