Self-hosted cost-sharing ledger for agricultural machinery — neighbours book each other's tractors and implements, Treckrr prices the work from a shared rate basis and issues tax-correct Austrian invoices. Go, PostgreSQL, Docker.
Treckrr is for a Maschinengemeinschaft: a handful of neighbouring farms that share machinery and settle up once a year. Bookings are priced from one agreed rate basis, so nobody argues about the hourly rate, and the year closes with a frozen invoice per neighbour that satisfies § 11 UStG.
The application UI is in German; this README is in English.
All assets are served locally — no CDNs, no tracking, strict CSP. Your data stays on your machine.
The screenshots follow your theme automatically — light on GitHub's light theme, dark on its dark theme.
| Anmelden | Übersicht | Beleg | Grundlage |
![]() |
![]() |
![]() |
![]() |
Jahresübersicht — every neighbour's hours, cost and payment state for the billing year, with batch invoicing and CSV export

- Bookings priced from a shared rate basis — tractor PS × load level, or per unit/area — with implements, rigs ("Gespanne"), receipt photos, recurring series, quick capture, copy-from-existing, a duplicate warning, and storno that voids without deleting. A helper from the Personenstamm can ride along with a rig booking — on the form, in the quick-entry table and in a series — as linked man-hours priced from their own rate.
- Rate bases per year: tractors, load levels, implements and rigs. Compare two bases side by side, and lock one so its prices stop moving.
- Recalculation: after a rate change, preview every affected booking old → new and apply it in one step. Bookings on an already-issued invoice are left alone.
- Billing years per neighbour with ledger positions, reversible payment cancellations, carry-forward between years, carrying members over from last year, plus archiving and GDPR anonymisation.
- Invoices frozen at issue time (§ 11 UStG snapshot) with sequential numbering, storno, credit notes, PDF, EPC QR code and optional e-mail with send tracking. Sammel-Festschreibung issues a whole year at once, showing per neighbour what would be issued, blocked or skipped.
- Austrian VAT modes: Kleinunternehmer (no VAT shown), flat-rate § 22, or standard taxation — company data, IBAN and payment terms flow into every document.
- Public receipt links — expiring, revocable, hash-stored tokens so a neighbour can view their invoice without an account.
- Account statement per neighbour across all years, as a page and as PDF.
- Dunning (Mahnwesen): overdue list, printable reminder, PDF, EPC QR, CSV export.
- Import/export: CSV bookings with preview, bank statement payment matching, per-neighbour GDPR export, year and neighbour CSV.
- Authentication: password + optional TOTP with recovery codes, passkeys (WebAuthn, user verification required), roles (admin / editor / viewer), and session management with per-device revocation.
- Append-only audit log enforced by a database trigger, filterable and exportable, with staggered retention (1 year operational, 7 years for § 132 BAO records).
- Encrypted backups (AES-256-GCM) on a schedule, optional off-box S3 target, guarded GUI restores and an offline CLI recovery workflow.
- PWA with offline booking capture that replays under the user who captured it when the connection returns.
- Full-text search with typo tolerance, statistics per year and across all years, light/dark theme, Prometheus metrics (opt-in).
Server-rendered Go templates with vanilla JS — no frontend build step, no CDN, strict CSP.
git clone https://github.com/d0linger/treckrr.git && cd treckrr
cp .env.example .envGenerate the secrets and put them in .env:
openssl rand -hex 32 # SESSION_SECRET
openssl rand -hex 32 # BACKUP_ENCRYPTION_KEY (optional, enables backups)Set at minimum SESSION_SECRET, ADMIN_PASSWORD and POSTGRES_PASSWORD — and put the same database password inside DATABASE_URL. The app refuses to start while any of them still holds the documented placeholder value. The local source-build profile explicitly enables direct HTTP for development; do not reuse that setting in production.
docker compose up -dOpen http://localhost:8080 and log in as admin. You are forced to change the bootstrap password on first login.
Prebuilt image instead of building locally (multi-arch, amd64 + arm64):
TRUSTED_PROXIES='REPLACE_WITH_PROXY_SOURCE_IP_OR_CIDR' \
docker compose -f docker-compose.ghcr.yml up -dThe GHCR profile is the production/TLS path: it forces Secure cookies, enables
proxy trust, and requires TRUSTED_PROXIES. Pin a release rather than tracking
latest with TRECKRR_TAG=1.4.
| Variable | Description | Default | Required |
|---|---|---|---|
| DATABASE_URL | Postgres DSN (URL or keyword/value form) | — | Yes |
| SESSION_SECRET | Signing key, min. 32 chars, not the placeholder | — | Yes |
| ADMIN_PASSWORD | Bootstrap admin password, changed at first login | — | Yes |
| POSTGRES_PASSWORD | Database password; must match DATABASE_URL |
— | Yes |
| POSTGRES_USER / POSTGRES_DB | Database role and name | treckrr |
No |
| ADMIN_USERNAME | Bootstrap admin login name. Created only while no active admin exists; a restart never promotes an existing account with this name | admin |
No |
| APP_PORT | Port inside the container | 8080 |
No |
| HOST_PORT | Published port on the host | 8080 |
No |
| HOST_BIND | Host interface to bind; 127.0.0.1 only reaches a proxy on the host itself |
0.0.0.0 |
No |
| COOKIE_SECURE | Force the Secure cookie flag |
true (local Compose overrides to false) |
No |
| ALLOW_INSECURE_HTTP | Explicit direct-HTTP opt-in for local development only | false (local Compose sets true) |
No |
| TRUST_PROXY | Honour X-Forwarded-For / -Proto from a reverse proxy |
false |
No |
| TRUSTED_PROXIES | Comma-separated CIDRs allowed to set forwarded headers | — | No |
| ENCRYPTION_SECRET | Data-at-rest key for TOTP secrets; pin to the OLD value before rotating SESSION_SECRET |
SESSION_SECRET |
No |
| RP_ID / RP_ORIGIN | WebAuthn relying party host and origin; must match the browser URL | localhost / http://localhost:8080 |
No |
| ADMIN_PASSWORD_RESET | Break-glass for ONE start: reset the bootstrap admin's password to ADMIN_PASSWORD, make the account admin again, revoke its sessions and passkeys. Set back to false afterwards |
false |
No |
| BACKUP_ENCRYPTION_KEY | Min. 16 chars; empty disables backups entirely | — | No |
| BACKUP_DIR / BACKUP_STATUS_FILE | Dump directory and status file | /backups |
No |
| BACKUP_KEEP | Dumps to retain — seeds the GUI value on first boot only, after that Admin → Backup wins | 7 |
No |
| BACKUP_ENCRYPTION_KEY_OLD | Previous key, for rotate-key (see below) |
— | No |
| BACKUP_REHEARSE_URL | Postgres URL allowed to create/drop a scratch DB; enables real restore rehearsals | — | No |
| BACKUP_REHEARSE_CRON | Optional 5-field cron for isolated restore rehearsals; requires rehearsal URL, backup key and a local dump. Empty keeps manual-only operation | — | No |
| S3_ENDPOINT / S3_BUCKET | Off-box backup target; empty disables it | — | No |
| S3_KEEP | Objects to keep in the bucket, 0 = all; seeds the GUI value on first boot only | 0 |
No |
| S3_ACCESS_KEY / S3_SECRET_KEY / S3_PREFIX | S3 credentials and mandatory installation-unique key prefix when S3 is enabled | — | No |
| S3_USE_SSL | TLS for the S3 endpoint | true |
No |
| SMTP_HOST / SMTP_FROM | E-mail delivery; both must be set to enable it | — | No |
| SMTP_PORT / SMTP_USER / SMTP_PASSWORD | SMTP credentials | 587 |
No |
| SMTP_STARTTLS | Use STARTTLS | true |
No |
| METRICS_TOKEN | Min. 16 chars; enables GET /metrics behind a bearer token |
— | No |
| LOG_FORMAT / LOG_LEVEL | text|json, debug|info|warn|error |
text / info |
No |
Changing BACKUP_ENCRYPTION_KEY on its own orphans every existing dump: new
backups use the new key, older ones can no longer be opened. Rotate instead:
# Stop every app instance/scheduler before rotating; keep the database running.
docker compose stop app
# BACKUP_ENCRYPTION_KEY = new key, BACKUP_ENCRYPTION_KEY_OLD = previous key.
# Use docker-compose.ghcr.yml as the base instead when that is your deployment.
docker compose -f docker-compose.yml -f compose.rotate-key.yml run --rm --no-deps app rotate-keyEvery local dump is re-encrypted, verified as restorable with the new key, and only
then replaced atomically. A dump that fails verification is left untouched and
reported, so a partial run degrades to "some files still use the old key" — never
to an unreadable archive. The command is resumable: run it again after fixing
whatever failed. Remove BACKUP_ENCRYPTION_KEY_OLD from the deployment environment
after success, then recreate the app with its new key. Do not destroy the old
key: S3, off-host copies and object versions are not rotated by this command.
Keep a versioned recovery-key record in separate secure storage for as long as
any archive still needs it. Rehearse a representative retained remote archive
with its original key before retiring any key. The previous key is deliberately
mapped only by the CLI override, not into the long-running app.
The same applies to ENCRYPTION_SECRET (TOTP secrets at rest): pin it to the old
value before rotating SESSION_SECRET, as the table above notes.
The backup panel's "Restore getestet" used to be stamped by a table-of-contents
read — that proves the file is a well-formed archive, not that it loads. Set
BACKUP_REHEARSE_URL to a Postgres URL that may create and drop a scratch
database, then:
docker compose run --rm app rehearse-restoreIt restores the newest dump into a unique treckrr_rehearsal_<random> database, checks the applied
migrations and the money tables, drops the scratch database and reports timings.
Only this stamps restore_tested; the cheap per-backup check now reports itself
separately as archive_verified.
Use a dedicated rehearsal server/role with CREATE DATABASE permission, not a production database owner. The scratch name is unique per invocation; existing databases are never dropped to make room. Status-file persistence failures make the command fail even when the restore drill itself succeeded.
The backup schedule is a cron expression set in the admin Backup panel, not an environment variable.
- Docker with Compose v2. No Go, Node or Postgres needed on the host — everything builds and runs in containers.
- RAM: the compose files cap the app at 768 MB and Postgres at 1 GB, so keep ~1.8 GB free. Idle use is roughly 15 MB and 31 MB.
- Port 8080 published on the host (
HOST_PORT). The database port is not published. - Migrations run automatically at startup and are forward-only — take a backup before upgrading.
Persist these two named volumes:
| Volume | Contents |
|---|---|
pgdata |
PostgreSQL data directory — all business data |
backups |
Encrypted dumps, status.json, and private plaintext scratch files while a backup or restore is running |
Backups next to the database are not backups: set the S3_* variables, or copy
only treckrr-*.dump.enc (and, if useful, status.json) to another machine.
Do not mirror the volume wholesale: .treckrr-plain-*.tmp files are private but
temporarily contain plaintext database archives during backup and restore work.
Restore is available to administrators in the Backup panel and as an offline CLI command. Both require typed confirmation. For the CLI, stop all app instances and external schedulers first; leave PostgreSQL running:
docker compose stop app # 120-second stop grace, not a forced kill
docker compose run --rm --no-deps app restore --test /backups/<file.dump.enc>
docker compose run --rm --no-deps app restore /backups/<file.dump.enc>
# Only after restore + reconciliation succeed and recovery checks pass:
docker compose up -d appServing binaries hold a database advisory lease; the CLI refuses a restore while a participating app is running. The GUI drains local requests and background maintenance, admits only one restore, and refuses while another participating instance is present. First upgrade: older binaries do not hold this lease; stop every older instance before relying on the safeguard. These leases do not replace fencing/stop procedures for other writers or a database connection loss. A failed or uncertain GUI restore/reconcile keeps maintenance enabled; inspect logs and complete recovery before restarting. A successful explicit restore migrates the schema and invalidates all restored sessions and WebAuthn challenges. The restore transaction itself omits their archived rows, so a crash immediately after the restore commit cannot resurrect a revoked login. Review restored passwords, disabled accounts, recovery credentials and revoked share links against post-backup security changes before reopening access.
Online archives and dump output are capped at 128 MiB, with one memory-heavy
backup operation admitted per app process. This also bounds S3 downloads and
verification; an oversized archive is rejected without deleting it. Older larger
archives remain recoverable via an offline CLI process with explicitly provisioned
RAM/tmp space and BACKUP_CLI_MAX_BYTES (bytes, 1 MiB–16 GiB). Pass this only to that
process (for Compose, run -e BACKUP_CLI_MAX_BYTES ... with the value supplied in
the environment), and raise that one-off container's memory/tmpfs limits in a
reviewed local override. Raising this variable alone does not allocate RAM.
The current whole-archive format needs several times the archive size plus the
64 MiB key-derivation workspace. Do not run overlapping CLI jobs in a live app's
memory budget.
S3 requires a normalized, installation-unique prefix. New objects carry a stable
ownership marker and are created conditionally, so a collision is reported rather
than overwritten. Retention is limited to owned flat treckrr-*.dump.enc objects
under that prefix and never deletes objects younger than 24 hours (or with unknown
dates). Existing untagged objects remain readable but are never auto-pruned.
S3_KEEP=0 keeps everything. Scope the S3 credentials to the same prefix.
Retention env values seed the GUI settings only on first boot; existing GUI values
remain authoritative.
The release workflow publishes a candidate, scans that exact digest for both
amd64 and arm64, attaches per-platform SBOM artifacts, and promotes the same
manifest index without rebuilding only after the gates pass. Repository names
are lowercased for registry use. Tags (including sha-*) remain mutable aliases;
pin production to the digest shown in the workflow summary. Serialized promotion
rejects superseded branch refs; operators must still publish semantic-version
tags in order so a historical release does not move a minor-version alias back.
The weekly supply-chain job rescans the published digest and, when configured,
repository variable PRODUCTION_IMAGE_DIGEST (sha256:..., the multi-arch index)
without rebuilding. Update that variable after each deployment. An unset value
reports unknown deployed-image coverage; scanning latest is not deployment
inventory. Candidate tags from failed gates must never be selected for deployment.
For local core Go checks, make check requires an isolated TEST_DATABASE_URL
on the treckrr-itest network and matching PG_MAJOR=16 or 18; it runs race and
tagged integration tests serially. make test-unit is explicitly unit-only.
Security scanners, browser tests, image builds and registry promotion remain
separate CI jobs, not claims made by the Make target.
A booking is priced from the rate basis, not typed in by hand. The tractor contributes its power at the load level chosen for the job; every implement on the rig adds its working width:
tractor rate = PS × cost per PS (of the load level)
machine rate = working width × cost per AB
rig rate = tractor rate + Σ machine rates
booking cost = hours × rig rate
Worked example — a 130 PS tractor at load level mittel (0.36 €/PS) pulling a 3.0 m power harrow (14.50 €/m), for 4.5 hours:
tractor 130 × 0.36 = 46.80 €/h
harrow 3.0 × 14.50 = 43.50 €/h
rig = 90.30 €/h
booking 4.5 × 90.30 = 406.35 €
Jobs that aren't billed by the hour use a unit instead — hectares, cubic metres, a flat charge — with quantity × unit price.
Every step is exact decimal arithmetic rounded to two places, never binary
floating point, so totals reconcile to the cent. The cost model lives in
internal/calc and is unit-tested against the values from the original
spreadsheet the app replaced.
Changing a rate later does not silently reprice past bookings. They keep the price they were booked at; the Recalculation view shows every affected booking old → new so the change is applied deliberately.
Every account has exactly one role. There is no per-object sharing — this is a single-tenant app where everyone who signs in sees the whole ledger.
| Role | Can | Cannot |
|---|---|---|
| admin | everything, plus users, company data, audit log and backups | — |
| editor | create and edit bookings, neighbours, rate bases, invoices, payments | reach anything under /admin |
| viewer | read everything, export CSV/PDF | any change except to their own account (password, 2FA, passkeys, sessions) |
A viewer's write attempt is refused server-side, not just hidden in the UI. An admin can reset another user's password or 2FA; both end that user's sessions immediately.
| Endpoint | Auth | Purpose |
|---|---|---|
GET /livez |
public | Process is alive. No database call — a DB outage must not make an orchestrator kill a healthy container. |
GET /readyz |
public | App and database reachable. Returns 503 during a restore. |
GET /healthz |
public | Legacy alias of /readyz. |
GET /metrics |
Bearer token | Prometheus text format. Only registered when METRICS_TOKEN is set and at least 16 characters. |
The container's own HEALTHCHECK polls /healthz every 30 s.
Metrics cover process state (treckrr_uptime_seconds, go_goroutines,
go_memstats_*) and the connection pool — treckrr_db_connections_open,
_in_use, _idle, _max_open, plus treckrr_db_wait_total and
treckrr_db_wait_seconds_total. Those two are the ones to alert on: they only
move when requests are queuing for a connection.
curl -H "Authorization: Bearer $METRICS_TOKEN" http://localhost:8080/metricsForward the standard headers and tell the app to trust them:
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
COOKIE_SECURE=true
ALLOW_INSECURE_HTTP=false
TRUST_PROXY=true
TRUSTED_PROXIES=10.0.0.5/32 # the proxy's address — see below
RP_ID=treckrr.example.org # host only, no scheme
RP_ORIGIN=https://treckrr.example.org- Set TRUSTED_PROXIES. The app refuses to start with
TRUST_PROXY=trueand an empty allow-list. Restrict it to your proxy, or setHOST_BIND=127.0.0.1— but only when the proxy is installed natively on this host or runs withnetwork_mode: host. A proxy in its own container on a bridge network cannot reach the host's loopback; put it on the same Docker network as the app and point it atapp:8080instead, publishing no host port at all. - X-Forwarded-For is read right-to-left. The right-most entry is the address the proxy actually observed; earlier ones are client-supplied. This assumes exactly one trusted hop.
- RP_ID / RP_ORIGIN must match the browser's URL, or passkeys silently fail.
- Cookies fail closed to
Secureand get the__Host-prefix. The only plain-HTTP exception is the explicit local-development combinationCOOKIE_SECURE=false,TRUST_PROXY=false,ALLOW_INSECURE_HTTP=true. - Let the app own HSTS. It already sends
max-age=31536000; includeSubDomainsover HTTPS. If the proxy adds a second header, browsers process only the first (RFC 6797 §8.1) and the other is dead.
The shipped Compose file is hardened by default; nothing below needs to be switched on.
| app | db | |
|---|---|---|
| User | non-root, UID 10001 | postgres (UID 70) |
| Root filesystem | read_only: true |
writable (data dir) |
| Capabilities | cap_drop: ALL |
default set |
no-new-privileges |
yes | yes |
| Memory / PID limit | 768 MB / 128 | 1 GB / 256 |
| Writable paths | tmpfs /tmp, capped at 320 MB |
pgdata volume |
The app writes nothing to disk — state is in PostgreSQL, logs go to stdout — so
the root filesystem stays read-only. /tmp is a capped tmpfs because
multipart uploads spill there, and a tmpfs is RAM: uncapped, a large upload
writes its way into host memory instead of failing cleanly.
The database port is not published. The app port is, on HOST_BIND
(default 0.0.0.0); set it to 127.0.0.1 if your proxy is local.
Everything works the same under rootless Docker or Podman.
- Passwords: bcrypt. A failed login runs a dummy comparison so a wrong username costs the same time as a wrong password.
- Sessions: 256-bit random tokens, stored only as SHA-256 hashes. Sliding 30-day expiry with a hard 90-day cap, revocable per device.
- Second factor: TOTP with one-time recovery codes, seeds encrypted at rest under a key derived separately from the session secret. Passkeys (WebAuthn) require user verification, and each ceremony is server-side and single-use. Known edge (go-webauthn 0.18): a client that returns unsolicited extension outputs fails the ceremony by design; password + TOTP remain available as the fallback, so a login is never lost — if a passkey suddenly stops working after a browser update, that is the first thing to check.
- Rate limits on login by IP and by target account, on the 2FA step, on password step-up, and on passkey challenge creation — all in PostgreSQL, so they survive a restart.
- CSRF: every state-changing request carries an HMAC token bound to the session cookie; the login form has its own seeded token.
- Headers: strict CSP with no
unsafe-inline,frame-ancestors 'none', nosniff,Referrer-Policy: same-origin, COOP/CORP same-origin, HSTS over HTTPS. No CDNs — every asset is embedded in the binary. - Audit log is append-only, enforced by a database trigger rather than application code.
- Financial history is protected by the database: neighbours and billing
years carrying bookings, payments, ledger entries or invoices cannot be
deleted (
ON DELETE RESTRICT). GDPR erasure is pseudonymisation, which keeps the § 132 BAO records intact. - Backups are AES-256-GCM encrypted with a key held separately from the session secret, and can be validated before a restore.
Report vulnerabilities privately — see SECURITY.md.
| Workflow | What it does |
|---|---|
| CI | go vet, go test -race against a real PostgreSQL service, go build, module verification and a tidiness check, golangci-lint |
| Security | gosec static scan and govulncheck, weekly as well as on push |
| Supply chain | Trivy over the repository and over the built image (the Alpine layer nothing else inspects), CycloneDX SBOM generated from the image |
| E2E | Playwright smoke test: login → booking → Beleg |
| DeadCode | unreachable-function detection |
| GODep / GitSecret | dependency review on PRs, gitleaks secret scanning |
| Docker | multi-arch image (amd64 + arm64) to GHCR — gated on the tests passing, so a red build publishes nothing |
Every action is pinned to a full commit SHA, base images are pinned by digest,
and workflow tokens are least-privilege (packages: write only on the job that
pushes).
MIT — see LICENSE. Issues and pull requests are welcome; see CONTRIBUTING.md for the build and test commands, and SECURITY.md for reporting vulnerabilities privately.



