Valid ZUGFeRD E-Invoices From Self-Hosted InvoicePlane in Docker
Monthly recurring invoices with InvoicePlane in Docker as valid ZUGFeRD 2.3 EN16931 PDF/A-3 files, checked with Mustang CLI. Patches and gotchas.
Two small companies I do the tooling for send the same few invoices every month. Same customers, same line items, only the month changes. Until now that was a Word file, copied and edited by hand on the 24th. I wanted that automated, and since e-invoicing is coming in Germany, I wanted the PDFs to be real e-invoices too, not just PDFs.
It's running now on self-hosted InvoicePlane in Docker. Getting it to produce e-invoices that a validator actually accepts took a couple of settings, a few core patches and one custom XML generator.
TL;DR: InvoicePlane 1.7 (MIT license) in a php:8.3-apache container plus MariaDB, behind an Apache reverse proxy. Recurring invoices come from InvoicePlane's cron URL, called by a small wrapper on the 24th. For valid ZUGFeRD 2.3 / EN16931 as PDF/A-3b: set the currency code to EUR, set tax rate decimal places to 2 (otherwise BR-CO-15 fails), patch the e-invoice and mPDF helpers, and validate every PDF with Mustang CLI. Keep the patched files in your backup, because an update overwrites them.
Short context on e-invoicing in Germany
I'm not a tax advisor, so check this for your own case. The rough state:
- Since 1 January 2025, businesses in Germany must be able to receive e-invoices from other businesses. An e-invoice here means a structured format following EN 16931, for example XRechnung or ZUGFeRD (from version 2.0.1, but not the MINIMUM and BASIC-WL profiles). A plain PDF is not an e-invoice.
- Sending e-invoices becomes mandatory in steps: from 2027 for companies with more than 800,000 EUR turnover in the previous year, from 2028 for everyone. Until then a normal PDF is still allowed for B2B.
ZUGFeRD is the hybrid option. It's a PDF/A-3 that a human can read, with an XML file embedded that a machine can read. For small companies whose customers still look at PDFs, that's the easiest way in. Nobody has to change how they read invoices, and the data is already there for whoever wants to import it.
Why InvoicePlane
First I looked at Odoo, but several companies in one account needed a bigger plan than this was worth. Then Invoice Ninja v5, self-hosted. Honestly it's a good tool and it does ZUGFeRD EN16931 out of the box. I had it running with a custom design that matched the old Word layout. The only problem was the Invoice Ninja logo in the PDF. Removing it needs their paid white-label license, and the license doesn't allow working around that, which is fair. For a handful of invoices a month it wasn't worth it to us, so I moved on.
InvoicePlane is MIT licensed, has no branding in the PDF, and the PDF templates are plain PHP files. It's older and simpler than Invoice Ninja. The e-invoice part needed work, see below.
The Docker setup
There's no official image I wanted to use, so I build a small one:
FROM php:8.3-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
libpng-dev libjpeg-dev libfreetype6-dev libzip-dev libicu-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install gd mysqli zip intl bcmath \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*
Check the extension list against the InvoicePlane requirements for your version. This is what I'd start with.
# /opt/invoiceplane/docker-compose.yml
services:
app:
build: .
ports:
- "127.0.0.1:8140:80"
volumes:
- ./app:/var/www/html
- ./custom_templates:/var/www/custom_templates:ro
depends_on:
- db
db:
image: mariadb:11
environment:
MARIADB_DATABASE: invoiceplane
MARIADB_USER: invoiceplane
MARIADB_PASSWORD: change-me
MARIADB_ROOT_PASSWORD: change-me-too
volumes:
- ./db:/var/lib/mysql
The app code is a bind mount, not baked into the image. That's on purpose. I need to patch core files, and I want those patches as plain files on the host where my backup sees them.
The port is bound to 127.0.0.1 only. The server runs KeyHelp, and the public side is an Apache vhost with a Let's Encrypt certificate that proxies to that port. The pattern is the same as in Apache reverse proxy for Docker apps, and the panel itself is in KeyHelp as a free Plesk alternative.
InvoicePlane's config is ipconfig.php in the app folder. The keys that mattered for me:
IP_URL=https://invoice.example.com
SESS_SAVE_PATH=/var/www/html/storage/sessions
DISABLE_SETUP=true
CUSTOM_TEMPLATES_FOLDER=/var/www/custom_templates
CUSTOM_INVOICE_TEMPLATES_PDF=Letter
Gotcha: session errors in the container
Without SESS_SAVE_PATH set to a writable folder inside the app, I got session errors in the container. Point it at a folder the web server user can write to. Also set DISABLE_SETUP=true right after the setup wizard, otherwise the setup is still reachable.
Gotcha: custom templates must be whitelisted
Putting a PHP file into the templates folder is not enough. InvoicePlane only uses custom PDF templates that are listed in CUSTOM_INVOICE_TEMPLATES_PDF. My template rebuilds the old Word layout and lives on a read-only mount, so an update of the app folder can't overwrite it.
How the data maps
InvoicePlane has no real "multiple companies" concept, so I mapped it like this:
- Sender company = an InvoicePlane user. Each company is a user with its own address and tax data. Nobody logs in with those.
- Number ranges = invoice groups. Each company has its own group with its own prefix. A small script on 1 January resets the next number to 1.
- Extra texts (subject, intro line, footer lines) = custom fields. I only use the TEXT type for those.
Recurring invoices and the cron
InvoicePlane creates recurring invoices when you call its cron URL. The key is in the settings.
curl -fsS "https://invoice.example.com/invoices/cron/recur/YOUR_CRON_KEY"
One catch: recurring invoices copy the line items word for word. If a line says "Consulting September", next month also says "Consulting September". So I don't call the URL directly from cron. A small wrapper does it:
# /etc/cron.d/invoices
0 5 24 * * root /opt/invoiceplane/monthly-run.py >> /var/log/invoices.log 2>&1
The wrapper calls the recur URL, replaces the month names in the new invoices, renders the PDFs and mails them to me. Nothing goes to a customer automatically. For now all customer contacts point to my own address, and I checked the amounts against the last hand-made invoices. Real addresses go in once I trust it.
For rendering I added a small CLI controller, because InvoicePlane's guest PDF links only work for invoices that were already sent. The mail goes out over a separate mailbox with SPF and DKIM set up, so it doesn't land in spam.
Making the e-invoice valid
InvoicePlane has e-invoice support built in, but out of the box I didn't get files that pass validation. What I changed:
1. Currency code. The currency code setting was still on the default USD. The PDF looked fine, but the XML takes its currency from that setting, so the e-invoice would have said USD. Set it to EUR.
2. Tax rate decimal places. It was set to 0. With that, amounts came out as whole euros and the totals in the XML didn't add up anymore. The validator reports that as BR-CO-15: the total with VAT must equal the total without VAT plus the VAT total. Setting tax rate decimal places to 2 fixed it.
3. My own XML generator. The shipped generator is based on Factur-X, the French twin of ZUGFeRD, and it wants a French SIREN company ID. I copied it into my own generator class without that requirement and with the German identifiers: tax number as FC, VAT ID as VA, and the electronic address with scheme EM. Then a config file that uses it for all customers.
4. Core patches in two helpers:
e-invoice_helper.php: the e-invoice code only ran if the sender had a tax number. One of the companies only has a VAT ID, so I changed the check to "tax number OR VAT ID". I also added the XMP extension schema that ZUGFeRD expects in the PDF metadata and set the conformance level toEN 16931.mpdf_helper.php: set mPDF to create PDF/A-3B. Without PDF/A-3 the embedded XML doesn't count.
I keep the original files next to the patches with a .orig suffix. That way a diff after an update shows right away what changed.
Validate with Mustang CLI
Don't trust "it embedded an XML". Validate. Mustang is a Java library for ZUGFeRD, and its CLI checks both the PDF/A part and the XML against the rules. I run it in a throwaway Java container so the server doesn't need a JRE:
docker run --rm \
-v /opt/mustang:/opt/mustang:ro \
-v "$PWD":/data -w /data \
eclipse-temurin:21-jre \
java -jar /opt/mustang/Mustang-CLI-2.26.0.jar --action validate --source invoice.pdf
The output is an XML report. Look at the summary status and at every failed rule. That's where BR-CO-15 showed up for me. After the four changes above, all invoices validated as ZUGFeRD 2.3 EN16931, PDF/A-3b, with factur-x.xml embedded.
Gotcha: updates overwrite your patches
The patches live in InvoicePlane core files. An update replaces them. So:
- The patched files, the
.origfiles and the generator are in a separate folder and in the backup. - After every update: re-apply the patches, render one invoice, run Mustang again. If it doesn't say valid, don't send.
Backups
A backup script runs every night at 03:30, keeps 30 days locally and mirrors to a storage box. The shape is simple:
#!/bin/bash
set -euo pipefail
STAMP=$(date -u +%Y%m%dT%H%MZ)
DEST=/var/backups/invoiceplane
mkdir -p "$DEST"
docker exec invoiceplane-db-1 sh -c 'mariadb-dump -u root -p"$MARIADB_ROOT_PASSWORD" invoiceplane' \
| gzip > "$DEST/db-$STAMP.sql.gz"
tar -czf "$DEST/files-$STAMP.tar.gz" -C /opt/invoiceplane app/ipconfig.php app/uploads custom_templates einvoice-patches
find "$DEST" -type f -mtime +30 -delete
Invoices have legal retention periods, so test the restore once, not only the backup. Also keep the rendered PDFs, not just the database. A re-render after a template change gives you a different PDF than the one the customer got.
Where it sits
This is the outgoing side. The incoming side, receipts and invoices from suppliers, I handle with a separate sorter. That one turns HTML mail receipts into PDFs with headless Chromium and matches them against the card statement with deterministic parsing.
Would I do it again with InvoicePlane? For a few recurring invoices, yes. It's small, the templates are plain PHP, and the license doesn't get in the way. You just have to own the patches and validate every time something changes.