Deployment
Quick start
cp .env.example .env # change the passwords first
docker compose up -d # database, migrations, applicationThe application is then on http://localhost:8080. docker compose up runs migrate to completion before starting app.
The image
Built on the official Jennifer interpreter image:
ARG JENNIFER_IMAGE=ghcr.io/jennifer-language/jennifer:mainThe sources declare # pragma-jennifer-version: >=0.25.0. No released interpreter satisfies that yet - 0.24.0 is the newest tag - and the pragma is a hard abort, so a released image would refuse to run the application at all. The main build reports itself as a development version, and a development build bypasses the floor. Pin this to :0.25.0 as soon as that release exists.
The Debian-based variant is used rather than the distroless one because the Finanzamt export shells out to PDF tools, which need a shell.
Building it
Use scripts/build-image.sh, which stamps the image with what it was built from - the image carries no .git, so the version has to be read at build time and passed in:
scripts/books.sh # the handbooks first; the COPY needs them
scripts/build-image.sh # -> app-expenses:latest
scripts/build-image.sh registry.example.com/familienkonto:2026-09-02A tag on this commit becomes the version; anything else leaves the version empty and sets only the commit, and a dirty tree appends +dirty - a working copy is not the commit it claims to be. The settings page then reads
Familienkonto 1.2.0 (built from a tag)
Familienkonto (Entwicklung, 0e173c1) (built from a commit)
Familienkonto (Entwicklung) (built by hand, or run from a checkout)and the same two values land in org.opencontainers.image.version and .revision, so docker inspect answers the question without starting the container. It is the one line that makes a bug report answerable.
docker build by hand still works and simply says Entwicklung.
The handbook has to be rendered first: the image copies book-output/ in, and the COPY fails without it - deliberately, so a missing manual is a build error rather than a dead Handbuch link in a running container.
scripts/books.sh # writes book-output/
docker build -f docker/Dockerfile -t app-expenses:latest .With OCR, which is the only optional tool:
docker build --build-arg WITH_OCRMYPDF=true \
-f docker/Dockerfile -t app-expenses:ocr .That is 749 MB against 436 MB for the default build; tesseract and ghostscript are the difference. Through compose the switch lives in .env (WITH_OCRMYPDF) and is read by docker compose build.
Installing ocrmypdf does not switch OCR on: APP_OCRMYPDF_BIN is empty by default, so set it to ocrmypdf at runtime as well. The binary being present and the feature being armed are two decisions, and the second one belongs to the installation rather than to the image.
For a swarm or any multi-node setup, build once and push - the nodes cannot build, and latest on three of them is three versions:
docker buildx build --platform linux/amd64,linux/arm64 \
--build-arg WITH_OCRMYPDF=true \
-f docker/Dockerfile -t registry.example.com/familienkonto:2026-09-01 --push .pdfcpu is fetched from its release tarball and the Dockerfile maps TARGETARCH onto the right one, so amd64, arm64, armv7 and i386 all build.
PDF tooling
| Tool | Role | Installed |
|---|---|---|
pdfcpu | primary merge engine | always, from its release tarball |
qpdf | merge fallback, repair, linearise | always |
pdftotext | reads text out of PDFs | always (poppler-utils) |
ocrmypdf | OCR for photographed receipts | only with --build-arg WITH_OCRMYPDF=true |
magick | the smaller copy of a photograph a browser is sent | always (imagemagick) |
OCR is opt-in because it pulls in tesseract and ghostscript and roughly triples the image. Without it, a photographed receipt is stored and shown normally - only its text is not searchable.
The application shells out in exactly four places, and these five tools are all of them: taxexport.j merges with pdfcpu or qpdf, receipts.j extracts text with pdftotext and ocrmypdf, and backup.j runs mariadb-dump and tar. A tool the code cannot name is a tool the image should not carry - pdftk was installed here until it turned out nothing could call it.
If neither merge engine is present, PDF attachments are not merged into the report: a placeholder page names each file and its checksum, and the ZIP carries the originals. Evidence is never silently left out.
Configuration
Everything comes from the environment; .env fills in what the environment does not set, and a real environment variable always wins.
| Setting | Meaning |
|---|---|
APP_DB_DSN | MariaDB DSN. No parseTime=true |
APP_DB_WAIT_SECONDS | how long to wait for the database at startup (default 60, 0 = one attempt) |
APP_DB_AUTOMIGRATION | apply pending migrations at startup (default false) |
APP_AUTH_MODE | dev, authelia or oidc |
APP_OIDC_* | the OpenID Connect client, in oidc mode; see Authentication |
APP_TRUSTED_PROXIES | required in authelia mode; unused in oidc mode |
APP_GROUP_ACCESS / APP_GROUP_ADMIN | the two groups read from Authelia |
APP_SECRET | signs cookies and CSRF tokens; required in authelia mode |
APP_BASE_CURRENCY | default EUR |
APP_COVERAGE_THRESHOLD_PERCENT | family-allowance threshold, default 50 |
APP_RECEIPT_DIR / APP_EXPORT_DIR | on the app-var volume in compose |
APP_DOCS_DIR | the rendered handbook, served at /docs; /app/book-output in the image |
APP_FLOAT_MINIMUM | the smallest credit the application spends by itself (default 1,00) |
APP_BACKUP_* | the nightly backup; see below |
APP_SMTP_* / APP_MAIL_* | outgoing notification mail; see below |
APP_MAX_UPLOAD_BYTES | largest accepted attachment; 10485760 is also the ceiling, see below |
APP_PDFCPU_BIN / APP_QPDF_BIN / APP_PDFTOTEXT_BIN / APP_OCRMYPDF_BIN | external binaries; empty disables one |
Starting without an orchestrator that orders things
docker compose up runs the database first, waits for its health check, runs the migrations to completion and only then starts the application. Swarm ignores depends_on, and Kubernetes never had it: there all three start together, and the application regularly wins the race against a database that still has a data directory to check.
Two things make that survivable, and both are on by default:
- The application waits for the database.
APP_DB_WAIT_SECONDS(60) bounds it, one line goes to the log while it waits, and it connects the moment the server answers. Only a server that is not listening yet is waited for - "Access denied" or an unknown database fails immediately, because that answer will be the same in a minute. - It says when the schema is not there. A start against an unmigrated database logs
SCHEMA: N migration(s) are not applied yet - run \fk migrate up\instead of failing one page at a time with "table expenses does not exist".
Migrations are a job somebody has to run - or the server can run them itself.
By hand, which is the default and what a cautious deployment does: apply them before the new version starts, with a backup already taken.
docker run --rm --env-file swarm.env $FK_IMAGE run bin/fk migrate upAPP_DB_AUTOMIGRATION=true, for a deployment that would rather not have a separate step: the server applies what is pending before it serves anything, and says so.
SCHEMA: 10 migration(s) applied (APP_DB_AUTOMIGRATION)Several replicas starting together are safe: the migration is taken under a MariaDB named lock (GET_LOCK), so the second task waits for the first instead of applying the same change beside it, and a task that dies mid-migration releases the lock when its connection closes rather than blocking the next start for ever. If the lock cannot be had within a minute, that server refuses to start rather than serving against a half-migrated schema.
What it does not do is take a backup, and it cannot roll a migration back. That is the argument for leaving it off on anything whose data would be missed.
When something crashes
A failing request does not take the server down. web.j runs every handler inside a try: an uncaught error is written to stderr as web: unhandled handler error: …, the request is answered 500, and the process carries on. That is deliberate on the framework's side - letting it propagate would make any one request a denial of service - and it means a bug in one page costs that page, not the installation.
A process that does exit is restarted by the orchestrator, not by the application. There is no supervisor inside the container, and there should not be: one process per container is what makes the restart somebody's job.
- Compose -
appcarriesrestart: unless-stopped;migrateisno(a one-shot),backupandmailareon-failure. - Swarm -
restart:is ignored.deploy.restart_policyapplies, and its default (condition: any,delay: 5s, unlimited attempts) already restarts a failed task. Set it explicitly if you want anything else.
What neither of those notices is a process that is alive but wedged. The image therefore carries a health check:
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD ["/usr/bin/jennifer", "run", "bin/healthcheck.j"]bin/healthcheck.j asks the server's own /healthz on the loopback and exits 0 or 1. Swarm replaces a task whose health check fails; plain Docker only marks it unhealthy, which a monitoring system can pick up.
/healthz answers ok without a session, without rendering and without touching the database. That is the point: a health check that queried the database would turn one slow database into every application task being killed and restarted at the same moment. Whether the database is reachable is what fk doctor answers, and what every real page answers by itself.
One file, stored once
An attachment is identified by the SHA-512 of its contents. When a family attaches the same document twice - one invoice covering two children, on both of their expenses - the rows stay separate and the file underneath is shared. Deleting one row leaves the file alone until the last row using it goes.
Sharing is within a household. Two families holding the same document is a coincidence, and letting one family's deletion reach the other's storage is not a coincidence worth arranging.
For an installation that predates this, fk receipt-rehash reads every attachment once, records what it is, points the later rows at the first copy and removes what nothing references:
reading every attachment; this touches each file once
12 hashed, 3 now sharing a file, 46,1 MB freedIt is safe to run twice - the second pass has nothing to do - and it is a command rather than a startup step because it reads every file a family owns.
Photographs are stored twice
A receipt photographed at full resolution is tens of megabytes. Sending that to a phone every time somebody opens the entry is what makes the page unusable, so an upload that is a JPEG or a PNG gets a display copy beside it - scaled to APP_DISPLAY_MAX_EDGE (2400 px) on the long side, quality 82, metadata stripped. Measured on a real 6000x4500 photograph: 8.58 MB original, 760 KB copy.
The original is never touched. It is the evidence a tax office would be shown, and it stays byte for byte as it left the camera; the export bundle and the ZIP still carry it. The listing offers both - Anzeigen opens the copy, Original the file itself - because a page that quietly showed something other than what was uploaded would be lying about evidence.
| Setting | Default | |
|---|---|---|
APP_IMAGEMAGICK_BIN | the tool; empty serves originals only | magick |
APP_DISPLAY_MAX_EDGE | longest edge of the copy, in pixels | 2400 |
It is best-effort, like the text extraction: a missing tool, a failure, or a picture already small enough leaves no copy, and the original is served instead. Being unable to shrink a photograph is not a reason to refuse it.
Two details worth knowing. The decode is bounded with -define jpeg:size=, so a 75-megapixel photograph never exists in memory at full size - without it ImageMagick allocates around 300 MB, and several uploads at once would end a small machine. And a copy that comes out no smaller than the original is thrown away rather than stored, because it would double the storage for nothing.
Request limits and what they cost in memory
The server is started with httpd.listenWith, not web.run - web.run takes the engine's own defaults, and its default request body is 10 MiB, which a full-resolution photograph from a recent phone exceeds. That failed as a bare 413 before any handler ran, so it could not even be reported in German.
| Setting | Default | |
|---|---|---|
APP_MAX_BODY_BYTES | largest request body read at all | 52428800 (50 MB) |
APP_MAX_INFLIGHT | requests handled at once | 40 |
APP_MAX_UPLOAD_BYTES | largest attachment a person may upload | 51380224 (49 MB) |
The first two multiply. A request body is read into memory, so the worst case this process can be asked to hold is APP_MAX_INFLIGHT x APP_MAX_BODY_BYTES - 40 x 50 MB is about 2 GB of RSS. Lower either one on a small machine, and size the container's memory reservation for it. The server says so at startup:
Limits: upload 49,0 MB, request body 50,0 MB, 40 at once (worst case 2,0 GB of memory)The upload limit stays about a megabyte under the body limit, because the file arrives inside a multipart envelope - boundaries, a Content-Disposition per part, the other fields of the form. A configuration that lets them meet is refused at startup: otherwise a file that passes our own check is refused by the transport one byte later, as a 413 rather than as the German sentence the limit has.
Above APP_MAX_BODY_BYTES the answer is still a bare 413 with no page around it - the handler never runs, so there is nothing to catch. A reverse proxy can return something friendlier (client_max_body_size in nginx), and the upload form states the limit so it should not be reached by surprise.
Behind a reverse proxy
The application does not terminate TLS and does not authenticate. Put it behind a proxy that does both, and give it the proxy's address:
APP_AUTH_MODE=authelia
APP_TRUSTED_PROXIES=10.0.0.1,172.16.0.0/12
APP_AUTHELIA_URL=https://sso.example.com/
APP_SECRET=<a long random string>The proxy must set Remote-User, Remote-Email, Remote-Name and Remote-Groups, and must strip those headers from incoming requests so a client cannot supply them itself. The trusted-proxy check is the second line of defence, not the first.
First run
docker compose run --rm cli user-add root --name "Administration" --admin
docker compose run --rm cli household-add "Familie Muster"
docker compose run --rm cli member-add martin --role master --name "Martin Muster"
docker compose run --rm cli member-add anna --role child --name "Anna Muster"
docker compose run --rm cli doctorThe administration account belongs to no family. From Verwaltung in the web interface it can create further accounts, lock them, grant or withdraw administration, and act as any of them to reproduce a problem - see Authentication for what that does and does not carry.
doctor is the first thing to run when something is wrong: it reports the configuration, whether the connection is strict, pending migrations, whether each directory is genuinely writable, and each external tool's real version.
Directories, at startup
Before it serves anything, the application checks every directory it writes into - APP_RECEIPT_DIR, APP_EXPORT_DIR, and APP_BACKUP_DIR when backups are armed - and says on the log what it found:
DIR: created receipts at /app/var/receipts
DIR: fixed the permissions of exports at /app/var/exports
DIR: backups at /backup is NOT writable (the directory is not writable) -
on the host, try `chown -R 10001:10001 /backup`Missing directories are created. A directory that exists but refuses a write has its permissions widened to rwxrwxr-x and is tried again - which fixes the usual case of a bind mount made by the wrong user, and cannot fix a directory owned by somebody else. Writability is decided by writing a file, not by reading the mode bits: under a mapped user id, an ACL or a read-only mount those two answers differ, and only one of them is the one that matters.
A directory that cannot be fixed is a warning, not a refusal to start. The pages that do not touch it work perfectly well, and an installation whose exports are misconfigured is better off running and saying so than crash-looping inside an orchestrator. fk doctor reports the same three.
State to back up
- the database - everything except the files
APP_RECEIPT_DIR- the attachments; the database only holds their paths and checksums
APP_EXPORT_DIR holds generated bundles and can be regenerated.
Nothing is sent until a relay is named. With APP_SMTP_HOST empty the application queues nothing, the mail service starts, says so and exits, and the Einstellungen page tells people that no post can arrive - which is better than a switch that quietly does nothing.
| Variable | |
|---|---|
APP_SMTP_HOST | the relay; empty means no mail at all |
APP_SMTP_PORT | default 587 |
APP_SMTP_SECURITY | starttls (587), tls (465, implicit) or none |
APP_SMTP_USER / APP_SMTP_PASS | SASL credentials; the mechanism is negotiated from the relay's EHLO |
APP_MAIL_FROM | the envelope sender; required as soon as a host is set |
APP_MAIL_FROM_NAME | the display name in front of it (default Familienkonto) |
APP_MAIL_SCHEDULE | how often the queue is drained (default */5 * * * *) |
APP_MAIL_DUMP_DIR | write messages here as .eml files and send nothing |
A request never talks to a mail server. An event writes a row into mail_queue in the same transaction as the action that caused it, and the mail service takes those rows to the relay. A settlement that is booked has been booked, whether or not anybody's SMTP server was answering; a relay that is down delays post and loses no bookkeeping.
A message the relay refuses goes back in the queue with the reason and is tried again, five times, and is then left as failed - never deleted, and never retried for ever:
docker compose run --rm cli mail status # pending / sent / failed
docker compose run --rm cli mail send # drain it now
docker compose run --rm cli mail retry # put the failed ones back
docker compose run --rm cli mail test --to me@example.comAPP_MAIL_DUMP_DIR is the way to see exactly what would go out before any of it does: set it, let something happen, and read the files. It is also what the tests use, so they need no mail server.
APP_BASE_URL matters here: it is what the links in the messages point at, so it has to be the address people actually type, not the container's port.
Backups
The backup service writes one archive a day into /backup: a mariadb-dump of the database and a copy of the receipt directory, in a single familienkonto-YYYYMMDD-HHMMSS.tar.gz. Both halves are needed - the rows point at files and the files mean nothing without the rows - and both are stored in formats that restore without this application:
tar -xzf familienkonto-20260830-031500.tar.gz
mariadb -u expenses -p expenses < database.sql
cp -a receipts/. /path/to/APP_RECEIPT_DIR/It is off by default. Arm it in .env:
| Variable | |
|---|---|
APP_BACKUP_ENABLED | true to run at all; without it the container starts, says so and exits |
APP_BACKUP_HOST_DIR | the host directory bind-mounted onto /backup (default ./backups) |
APP_BACKUP_DIR | where the container writes (default /backup) |
APP_BACKUP_KEEP | how many archives to keep; 0 keeps every one (default 14) |
APP_BACKUP_SCHEDULE | five-field cron expression in the container's TZ (default 30 3 * * *) |
The directory has to be writable by the container's user (uid 10001), and a directory you create yourself is not:
mkdir -p ./backups
sudo chown 10001:10001 ./backupsWithout that the first run stops with "the backup directory /backup is not writable" rather than a mkdir: permission denied naming a temporary path nobody has heard of.
/backup is a bind mount, not a named volume: a backup that lives in the same Docker installation as the database it protects has solved nothing. Point APP_BACKUP_HOST_DIR at something that gets copied off the machine.
There is no cron daemon in the image. fk backup --schedule is the loop - it computes the next fire with the cron module, sleeps in half-minute slices so docker stop is noticed, and lets compose restart it if it dies. A failed run logs and waits for tomorrow rather than ending the schedule.
The same command without --schedule takes one backup now, which is what to run before an upgrade:
docker compose run --rm cli backupfk doctor reports the setting, the directory and the newest archive, because a backup nobody has looked at since it was configured is the usual way of having no backup.
The password is never on a command line - ps is readable by anyone on the host - but in a --defaults-extra-file written 0600 and deleted after the dump.
http and https
Session and CSRF cookies are marked Secure when APP_BASE_URL starts with https://, and not otherwise. That is not a preference: a Secure cookie is never sent back over plain http, so an installation served over http with the flag set cannot complete a single form - including the sign-in - while looking perfectly healthy.
So run it behind TLS and set APP_BASE_URL accordingly; a plain-http installation still works, with the cookies unprotected against a network attacker, which is the honest trade for a laptop or a demo.
Downloads and umlauts
Content-Disposition is an ASCII header, so a receipt called Rechnung Müller.pdf is named twice: once transliterated (Rechnung Mueller.pdf) for clients that read only the plain form, and once percent-encoded (filename*=UTF-8''…) for everything since RFC 6266. Browsers that understand the second prefer it; the first is what the others get, and neither carries a byte the header cannot hold.