Zum Inhalt springen

Einrichtung mit Docker Compose ​

openbill läuft als Docker-Compose-Stack: die App (baeroe/openbill, für linux/amd64 und linux/arm64), MariaDB 11.4 und Gotenberg 8, das die PDFs erzeugt. Du brauchst Docker mit Compose v2 und für HTTPS einen Reverse Proxy davor (siehe Reverse Proxy).

Deine Instanz, deine Verantwortung

openbill ist keine Steuer- oder Rechtsberatung und kommt ohne Gewährleistung (MIT-Lizenz). Ob deine Rechnungen und dein Archiv die für dich geltenden Vorschriften erfüllen (z. B. GoBD), klärst du mit deiner Steuerberatung; siehe E-Rechnung.

Schnellstart: einfügen und starten ​

Eine Compose-Datei mit allem drin, ohne .env:

  1. Füge den Block unten in Dockge oder Portainer als neuen Stack ein oder speichere ihn auf deinem Server als compose.yaml.
  2. Ändere die vier Werte ganz oben: APP_URL (die https-Adresse deines Reverse Proxys), ADMIN_EMAIL, ADMIN_PASSWORD und DB_PASSWORD. Solange ein Passwort noch change-me lautet, startet openbill nicht.
  3. Starte den Stack (Dockge/Portainer: Deploy) oder führe docker compose up -d aus. Warte im Log von openbill auf openbill: ready und öffne dann APP_URL. openbill lauscht auf Port 7739 des Servers: Richte deinen Reverse Proxy auf http://<server>:7739 (siehe Reverse Proxy).

Das Owner-Konto entsteht beim ersten Start; danach kannst du die Zeile ADMIN_PASSWORD löschen. DB_PASSWORD bleibt, wie es ist: MariaDB speichert es beim ersten Start. Das Root-Passwort von MariaDB ist zufällig und wird nicht gebraucht (Backups nutzen den Benutzer openbill).

yaml
# openbill in one file, no .env needed. Paste it into Dockge or Portainer
# (or save it as compose.yaml), change the values below, deploy.
# Docs: https://openbill.dev/en/setup
x-openbill: &openbill-env
  # public https address (your reverse proxy forwards it to port 7739)
  APP_URL: https://invoices.example.com
  # owner account, created on the first start (password: 8+ characters)
  ADMIN_EMAIL: admin@example.com
  ADMIN_PASSWORD: change-me
  # database password: MariaDB keeps it from the first start on
  DB_PASSWORD: &db-password change-me
  # proxy on another host? Only its X-Forwarded-* headers count then:
  # TRUSTED_PROXIES: 192.168.1.10
# openbill refuses to start while a password is still change-me.

# Data lives in the volumes openbill-data (PDFs/XMLs, templates, uploads, the
# generated APP_KEY) and openbill-db (MariaDB). Removing the stack keeps them
# unless you delete the volumes too.
services:
  openbill:
    # pin a version with baeroe/openbill:0.1.0
    image: baeroe/openbill:latest
    restart: unless-stopped
    depends_on:
      mariadb:
        condition: service_healthy
      gotenberg:
        condition: service_healthy
    # your reverse proxy forwards to port 7739 of this host (also from a container, like Nginx Proxy Manager).
    # Only a proxy on this host? "127.0.0.1:7739:8080" keeps the port local.
    ports:
      - "7739:8080"
    environment:
      <<: *openbill-env
      DB_HOST: mariadb
      DB_DATABASE: openbill
      DB_USERNAME: openbill
      GOTENBERG_URL: http://gotenberg:3000
    volumes:
      - openbill-data:/var/www/html/storage/app
    # Laravel answers (/up) with the host of APP_URL – the image's own check only pings nginx and PHP-FPM
    healthcheck:
      test: ["CMD", "sh", "-c", "h=$${APP_URL#*://}; h=$${h%%/*}; curl -fsS -o /dev/null -H \"Host: $${h%%:*}\" http://localhost:8080/up"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 120s
    networks:
      - default
      - backend

  mariadb:
    image: mariadb:11.4.13@sha256:1292844148b311e4ed4300022a996d39083f415a963e970cf47cad1b3b18e3a6
    restart: unless-stopped
    # refuses the placeholder before MariaDB stores it in its volume, then starts as usual
    entrypoint:
      - sh
      - -c
      - 'if [ "$$MARIADB_PASSWORD" = change-me ]; then echo "openbill: set DB_PASSWORD at the top of the compose file (still change-me)" >&2; exit 1; fi; exec docker-entrypoint.sh "$$@"'
      - docker-entrypoint.sh
    # Binary logging is off in this image. The document lock triggers need SUPER or this switch as soon as someone
    # turns it on (replication, point-in-time backups), so it is set either way.
    command:
      - --log-bin-trust-function-creators=1
    environment:
      MARIADB_DATABASE: openbill
      MARIADB_USER: openbill
      MARIADB_PASSWORD: *db-password
      # nobody needs root: backups use the openbill user (docs: backup)
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
      # runs mariadb-upgrade after a new MariaDB patch version
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - openbill-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s
    networks:
      - backend

  # PDF rendering. Templates may come from untrusted packages, so Chromium runs no JavaScript and loads nothing but
  # Gotenberg's own files and data: URLs.
  gotenberg:
    image: gotenberg/gotenberg:8.37.0@sha256:f29984bd1e226bf1b93ba90af06000afa8b315853e99d27b9aaa41b93f15c769
    restart: unless-stopped
    command:
      - gotenberg
      - --chromium-disable-javascript=true
      - --chromium-deny-list=^file:(?!//\/tmp/).*
      - --chromium-deny-list=^(?!(file|data):).*
      - --chromium-deny-private-ips=true
      - --chromium-deny-public-ips=true
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks:
      - backend

networks:
  default:
  # MariaDB and Gotenberg: reachable from openbill only, no published ports, no internet access
  backend:
    internal: true

volumes:
  openbill-data:
  openbill-db:

Wer die Einstellungen lieber in einer eigenen .env neben der Compose-Datei hat (etwa, um die Compose-Datei bei Updates unverändert zu lassen), nimmt stattdessen den Weg unten.

Dateien ​

bash
mkdir openbill && cd openbill
curl -fsSLO https://raw.githubusercontent.com/baeroe/openbill/main/docker-compose.yml
curl -fsSL -o .env https://raw.githubusercontent.com/baeroe/openbill/main/.env.docker.example

.env ​

Fülle .env vor dem ersten Start aus. Die Passwörter sind in der Vorlage absichtlich leer: Solange die Datenbank-Passwörter fehlen, bricht docker compose up mit DB_PASSWORD missing (bzw. DB_ROOT_PASSWORD) ab, und ohne ADMIN_PASSWORD legt der erste Start keinen Owner an (das Log meldet nobody can log in).

VariablePflichtBedeutung
APP_URLjaÖffentliche Adresse mit https://, z. B. https://invoices.example.com. Links, der Toolkit-Download und der erlaubte Host-Header kommen daher: Anfragen an jeden anderen Host bekommen 400.
ADMIN_EMAIL, ADMIN_PASSWORDerster StartOwner-Konto (das Passwort ist in der Vorlage leer), nur beim ersten Start angelegt (Passwort mindestens 8 Zeichen). Spätere Änderungen fassen das bestehende Konto nicht an. Entferne ADMIN_PASSWORD aus .env, sobald du dich angemeldet hast, siehe Passwörter.
DB_PASSWORD, DB_ROOT_PASSWORDjaMariaDB-Passwörter (in der Vorlage leer). MariaDB speichert sie beim ersten Start; änderst du sie danach in .env, klappt die Verbindung nicht mehr.
OPENBILL_BIND, OPENBILL_PORTneinAdresse und Port auf dem Host, Standard 0.0.0.0 und 7739 (der Container lauscht auf 8080). OPENBILL_BIND=127.0.0.1 hält den Port lokal, für einen Proxy auf demselben Host (nicht im Container), siehe Reverse Proxy.
TRUSTED_PROXIESneinIP-Adressen oder CIDR-Netze (keine Hostnamen), deren X-Forwarded-*-Headern openbill glaubt, kommagetrennt. Standard: localhost und die privaten Netze. Optional auf deinen Proxy einschränken, siehe Reverse Proxy.
OPENBILL_IMAGEneinStandard baeroe/openbill:latest. Eine Version festhalten mit baeroe/openbill:0.1.0.
APP_KEYneinSchlüssel für Sitzungen und Cookies. Ohne ihn erzeugt openbill beim ersten Start einen und legt ihn im Daten-Volume ab.

Starten ​

Die Anmeldung einer frischen Instanz

bash
docker compose up -d
docker compose logs -f openbill    # auf "openbill: ready" warten, dann Strg+C

Öffne APP_URL und melde dich mit ADMIN_EMAIL / ADMIN_PASSWORD an.

Bei jedem Start

  1. wartet der Container bis zu 60 Sekunden auf die Datenbank (DB_WAIT_TIMEOUT); ist sie nicht erreichbar, beendet er sich mit openbill: database … not reachable und Docker startet ihn neu,
  2. erzeugt er einmalig den APP_KEY, falls du keinen gesetzt hast,
  3. migriert er die Datenbank,
  4. spielt er die mitgelieferten Templates (Klar, Kante, Leise) in der Version des Images ein,
  5. legt er das Owner-Konto an, falls es noch nicht existiert,
  6. baut er seine Caches.

Passwörter ​

ADMIN_PASSWORD braucht nur der erste Start. Sobald du dich angemeldet hast, lösche die Zeile aus .env und führe docker compose up -d aus, damit das Passwort weder in der Datei noch in der Umgebung des Containers steht. Ohne die Variable überspringt der Container das Anlegen des Owners; er warnt im Log nur, wenn es noch gar kein Konto gibt.

Sein eigenes Passwort ändert jede Person unter Einstellungen → Dein Konto (siehe Einstellungen). Ein vergessenes Passwort wird auf dem Server zurückgesetzt; der Befehl fragt das neue zweimal ab, ohne es anzuzeigen:

bash
docker compose exec openbill php artisan app:reset-password owner@example.com

Ohne Terminal liest er stattdessen die erste Zeile seiner Eingabe, z. B. docker compose exec -T openbill php artisan app:reset-password owner@example.com < neues-passwort.txt. Das Passwort ist nie ein Argument und landet so nicht in der Shell-Historie. Das Zurücksetzen meldet das Konto überall ab; seine API-Tokens bleiben gültig.

Was wo liegt ​

WasWo
Archivierte PDFs und E-Rechnungs-XMLs, Template-Assets, hochgeladene Bilder und Logos, der erzeugte APP_KEY (.app-key)Volume openbill-data (/var/www/html/storage/app im Container)
DatenbankVolume openbill-db (MariaDB)
Einstellungen.env neben docker-compose.yml oder der Block oben in der Standalone-Compose-Datei

Container kannst du jederzeit neu anlegen, die Volumes bleiben. docker compose down -v löscht sie und damit jedes Dokument. Sichere beide Volumes, siehe Datensicherung.

Dienste ​

  • openbill: nginx und PHP-FPM auf Port 8080, läuft als www-data; den Code im Image kann er nur lesen. Gilt als gesund, sobald Laravel /up für den Host aus APP_URL beantwortet.
  • mariadb: nur für openbill erreichbar (internes Netz, kein veröffentlichter Port). Läuft mit --log-bin-trust-function-creators=1: Abgeschlossene Dokumente schützen Datenbank-Trigger, und Trigger anlegen braucht diesen Schalter (oder das SUPER-Recht), sobald Binärlogs eingeschaltet sind.
  • gotenberg: macht aus HTML ein PDF/A-3. Nur für openbill erreichbar und ohne Internetzugang. Templates können aus fremden Paketen kommen, deshalb führt Chromium kein JavaScript aus und lädt nur Gotenbergs eigene Arbeitsdateien und data:-URLs.

Eigene MariaDB ​

Statt des Dienstes mariadb eine vorhandene MariaDB 11.4 nutzen: Dienst und seinen depends_on-Eintrag entfernen, für openbill DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME und DB_PASSWORD setzen und dem Benutzer alle Rechte auf seiner Datenbank geben (auch TRIGGER). Sind dort Binärlogs eingeschaltet, setze log_bin_trust_function_creators = 1 in der Server-Konfiguration – sonst scheitert die erste Migration mit Fehler 1419 („You do not have the SUPER privilege and binary logging is enabled“).

Sprache ​

Die App gibt es auf Englisch und Deutsch, Standard ist Englisch. Die Sprache steht in der URL (APP_URL/en/…, APP_URL/de/…); umschalten kannst du im Menü. Dokumente haben ihre eigene Sprache, unabhängig von der App.

Versionen ​

latest folgt jedem Release. Eine Version festhalten mit OPENBILL_IMAGE=baeroe/openbill:0.1.0 in .env. Aktualisieren: Aktualisieren.

docker-compose.yml ​

Die vollständige Compose-Datei, dieselbe, die curl oben aus dem main-Branch lädt. Meist bleibt sie, wie sie ist: Alles, was du einstellst, kommt in die .env.

yaml
# openbill, self-hosted: docs/en/setup.md. Put a .env next to this file (template: .env.docker.example).
# Data lives in the volumes openbill-data (documents' PDFs/XMLs, templates, uploads, the generated APP_KEY) and
# openbill-db (MariaDB). `docker compose down` keeps them, `docker compose down -v` deletes them.
services:
  openbill:
    image: ${OPENBILL_IMAGE:-baeroe/openbill:latest}
    restart: unless-stopped
    depends_on:
      mariadb:
        condition: service_healthy
      gotenberg:
        condition: service_healthy
    # reachable on all addresses of the host, so a reverse proxy anywhere (also one in a container, like Nginx Proxy
    # Manager) forwards to host:7739. OPENBILL_BIND=127.0.0.1 keeps it local (docs/en/reverse-proxy.md).
    ports:
      - "${OPENBILL_BIND:-0.0.0.0}:${OPENBILL_PORT:-7739}:8080"
    environment:
      APP_URL: "${APP_URL:?APP_URL missing (public https address, see .env.docker.example)}"
      # optional: without it, a key is generated once and kept in the data volume
      APP_KEY: "${APP_KEY:-}"
      ADMIN_EMAIL: "${ADMIN_EMAIL:?ADMIN_EMAIL missing}"
      # only for the first start (app:ensure-admin creates the owner once); remove it from .env afterwards
      ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
      DB_HOST: mariadb
      DB_DATABASE: openbill
      DB_USERNAME: openbill
      DB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD missing (set it in .env before the first start)}"
      GOTENBERG_URL: http://gotenberg:3000
      # optional: addresses whose X-Forwarded-* headers are believed (comma-separated); default the private networks
      TRUSTED_PROXIES: "${TRUSTED_PROXIES:-}"
    volumes:
      - openbill-data:/var/www/html/storage/app
    # Laravel answers (/up) with the host of APP_URL – the image's own check only pings nginx and PHP-FPM
    healthcheck:
      test: ["CMD", "sh", "-c", "h=$${APP_URL#*://}; h=$${h%%/*}; curl -fsS -o /dev/null -H \"Host: $${h%%:*}\" http://localhost:8080/up"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 120s
    networks:
      - default
      - backend

  mariadb:
    image: mariadb:11.4.13@sha256:1292844148b311e4ed4300022a996d39083f415a963e970cf47cad1b3b18e3a6
    restart: unless-stopped
    # Binary logging is off in this image. The document lock triggers need SUPER or this switch as soon as someone
    # turns it on (replication, point-in-time backups), so it is set either way.
    command:
      - --log-bin-trust-function-creators=1
    environment:
      MARIADB_DATABASE: openbill
      MARIADB_USER: openbill
      MARIADB_PASSWORD: "${DB_PASSWORD:?DB_PASSWORD missing (set it in .env before the first start)}"
      MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:?DB_ROOT_PASSWORD missing (set it in .env before the first start)}"
      # runs mariadb-upgrade after a new MariaDB patch version
      MARIADB_AUTO_UPGRADE: "1"
    volumes:
      - openbill-db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 30s
    networks:
      - backend

  # PDF rendering. Hardened like in development (.ddev/docker-compose.gotenberg.yaml): templates may come from untrusted
  # packages, so Chromium runs no JavaScript and loads nothing but Gotenberg's own files and data: URLs.
  gotenberg:
    image: gotenberg/gotenberg:8.37.0@sha256:f29984bd1e226bf1b93ba90af06000afa8b315853e99d27b9aaa41b93f15c769
    restart: unless-stopped
    command:
      - gotenberg
      - --chromium-disable-javascript=true
      - --chromium-deny-list=^file:(?!//\/tmp/).*
      - --chromium-deny-list=^(?!(file|data):).*
      - --chromium-deny-private-ips=true
      - --chromium-deny-public-ips=true
    healthcheck:
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:3000/health"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 20s
    networks:
      - backend

networks:
  default:
  # MariaDB and Gotenberg: reachable from openbill only, no published ports, no internet access
  backend:
    internal: true

volumes:
  openbill-data:
  openbill-db: