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:
- Füge den Block unten in Dockge oder Portainer als neuen Stack ein oder speichere ihn auf deinem Server als
compose.yaml. - Ändere die vier Werte ganz oben:
APP_URL(die https-Adresse deines Reverse Proxys),ADMIN_EMAIL,ADMIN_PASSWORDundDB_PASSWORD. Solange ein Passwort nochchange-melautet, startet openbill nicht. - Starte den Stack (Dockge/Portainer: Deploy) oder führe
docker compose up -daus. Warte im Log vonopenbillaufopenbill: readyund öffne dannAPP_URL. openbill lauscht auf Port7739des Servers: Richte deinen Reverse Proxy aufhttp://<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).
# 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
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).
| Variable | Pflicht | Bedeutung |
|---|---|---|
APP_URL | ja | Ö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_PASSWORD | erster Start | Owner-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_PASSWORD | ja | MariaDB-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_PORT | nein | Adresse 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_PROXIES | nein | IP-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_IMAGE | nein | Standard baeroe/openbill:latest. Eine Version festhalten mit baeroe/openbill:0.1.0. |
APP_KEY | nein | Schlüssel für Sitzungen und Cookies. Ohne ihn erzeugt openbill beim ersten Start einen und legt ihn im Daten-Volume ab. |
Starten

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
- wartet der Container bis zu 60 Sekunden auf die Datenbank (
DB_WAIT_TIMEOUT); ist sie nicht erreichbar, beendet er sich mitopenbill: database … not reachableund Docker startet ihn neu, - erzeugt er einmalig den
APP_KEY, falls du keinen gesetzt hast, - migriert er die Datenbank,
- spielt er die mitgelieferten Templates (Klar, Kante, Leise) in der Version des Images ein,
- legt er das Owner-Konto an, falls es noch nicht existiert,
- 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:
docker compose exec openbill php artisan app:reset-password owner@example.comOhne 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
| Was | Wo |
|---|---|
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) |
| Datenbank | Volume 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 alswww-data; den Code im Image kann er nur lesen. Gilt als gesund, sobald Laravel/upfür den Host ausAPP_URLbeantwortet. - 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.
# 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: