A self-hosted, plaintext-relational finance app. GoBD-grade invoicing/accounting — one app you run on your own box.
Ledgerline replaces a handful of separate self-hosted services with one coherent, owner-scoped tool. It is plaintext-relational: every module is an ordinary relational table (one row per record), so your data stays queryable, searchable, joinable and easy to back up — no opaque blobs, no client-side crypto to lock you out of your own data.
Confidentiality at rest is an infrastructure concern (full-disk encryption + encrypted backups), not application paranoia. What the app enforces hard is the access boundary: TLS/HSTS, mandatory two-factor auth, a strict CSP, per-record owner-scope on every endpoint, and encrypted-at-rest operational secrets only (SMTP passwords, backup passphrases — never in a DB dump).
| Module | What it does |
|---|---|
| Finance | Invoices (GoBD numbering, ZUGFeRD/Factur-X e-invoice, PDF + email), quotes, product/stock ledger, project planning (tasks + time tracking → invoicing), payment methods, bank-statement import (MT940/CSV) with receipt matching, standalone receipts, business partners, VAT-return / EÜR reports, duplicate detection, category suggestions, dunning. |
Plus infrastructure (not a "module"): first-party auth (Laravel Fortify — email + password + TOTP 2FA + WebAuthn/passkeys), multi-user admin & groups, an admin security portal (request log + IP/user blocking), backups (S3/B2/SFTP/WebDAV, GFS rotation, restore), Paperless integration (for Finance receipts), notifications (SMTP/ntfy/webhook
- per-device push), device pairing for mobile, and a company/invoice profile.
Mail, Notes, Tasks, Calendar, Contacts, Gallery, Files and Servers were removed (the app is finance only now).
The repository is physically split so the two halves only ever meet over the API:
frontend/ standalone Vue 3 SPA (own package.json + Vite build → dist/)
backend/ Laravel API (app, config, routes, database, lang, tests, …)
Dockerfile multi-stage: Node builds the SPA, PHP builds the backend and serves
the built SPA from public/ (one image serves both)
docker-compose.yml, docker/, scripts/, .github/ deploy + CI
openapi.yaml the API contract — the single boundary between the two halves
The boundary is /api/v1 and nothing else:
- Bearer tokens only. The SPA authenticates with a Sanctum token in
localStorageand sendsAuthorization: Bearer …. There are no cookies, no CSRF, no server-rendered session state — every request is stateless. - A configurable API origin. The SPA reads
VITE_API_URLat build time; every request (including raw blob downloads and<img>/<iframe>stream URLs) goes through the API client, so the frontend can live on the same origin as the backend or on a completely different host. - The contract is
openapi.yaml. Every route, request/response shape, status code and error type is documented there and kept in lockstep with the routes by CI.
Because the frontend depends on the backend only through this HTTP contract, the
Laravel backend is replaceable. A future Go or Python implementation of the same
/api/v1 surface (bearer auth, same JSON shapes, same byte-stream endpoints) is a
drop-in swap — the frontend changes nothing but VITE_API_URL. openapi.yaml is the
spec to implement against; backend/lang/*.php remains the translation source the SPA
compiles from at build time.
The only build-time coupling is that the SPA compiles its i18n from backend/lang/*.php
(the single translation source). A non-PHP backend can either keep those PHP files as
data or export them to JSON — nothing at runtime depends on Laravel.
The production box is not exposed to the public internet directly. It is reachable over a NetBird overlay (WireGuard mesh):
browser / mobile app ──TLS──▶ Caddy (NetBird edge) ──HTTP──▶ backend :8300 (overlay IP)
https://home.pinlo.me (port not publicly routed)
- Caddy terminates TLS at the edge and reverse-proxies to the app container on the
NetBird overlay (
http://<overlay-ip>:8300, host header passed through). The app port is never routed publicly — only the overlay reaches it. - The backend runs with
FORCE_HTTPS=true(so all generated URLs arehttps://behind the plain-HTTP proxy hop),SESSION_SECURE_COOKIE=true(enables HSTS + COOP), andTRUSTED_PROXIESset to the NetBird/CGNAT ranges (never*) so the real client IP reaches the audit log. - Mobile apps join the same NetBird network and talk to
https://home.pinlo.me/api/v1with a device bearer token (issued via QR/CLI pairing, subject to the shared device cap). Native HTTP clients are not CORS-restricted, so they need nothing beyond the token and network reachability. Push works via a per-device UnifiedPush endpoint. - Browsers load the SPA from the same origin (
https://home.pinlo.me), so no CORS is involved in the default single-image deployment.
If you instead host the SPA on a different origin than the API (see
Standalone frontend), set
CORS_ALLOWED_ORIGINS on the backend to that origin — browser calls then pass CORS while
the bearer-token model is unchanged.
The production image is built by CI and pulled onto the box (no on-box builds).
One image, both halves (default). The multi-stage Dockerfile:
- assets stage (Node): builds
frontend/→frontend/dist(and self-hosts the tesseract OCR worker), stamping the version from theAPP_VERSIONbuild-arg. - runtime stage (FrankenPHP): installs the backend, then copies
frontend/distintopublic/. FrankenPHP serves the SPA's static assets directly and streamsindex.htmlfor unknown routes;/api/v1,/upand the byte-stream endpoints are served by Laravel Octane in the same process.
# On the box (pulls the CI-built GHCR image, recreates app/worker/scheduler):
git fetch --tags && git reset --hard vX.Y.Z
./scripts/deploy-pull.sh vX.Y.Zdocker-compose.yml, the deploy .env (compose env-file with the image tag, DB, app
config) and scripts/ stay at the repo root; migrations run on app start. The db
(PostgreSQL 18 + pgvector — a leftover from the removed Gallery module, kept for now),
valkey (cache/queues) and the optional agent (bounded Docker-control sidecar for the
admin System dashboard) / backup profiles are defined in compose.
The frontend can also be built and hosted on its own (nginx, a CDN, object storage), with the backend as a pure API:
cd frontend
VITE_API_URL=https://api.example.com VITE_APP_VERSION=vX.Y.Z npm run build # → dist/Serve dist/ with an SPA history fallback (try_files $uri /index.html) and set
CORS_ALLOWED_ORIGINS=https://app.example.com on the backend. The backend then only ever
serves /api/v1, /up and the byte streams.
# Backend (Laravel API)
cd backend
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate
php artisan serve # API on http://localhost:8000
# Frontend (Vue SPA) — second terminal
cd frontend
npm install
VITE_API_URL=http://localhost:8000 npm run dev # Vite dev serverQuality gates (all green before a release):
- backend:
cd backend && vendor/bin/pint && vendor/bin/phpstan analyse(level 10)&& php artisan test, plus EN/DE/RU translation-key parity. - frontend:
cd frontend && npm run typecheck && npm run lint && npm run build && npm run test:js.
Environment-driven. The essentials:
| Variable | Purpose |
|---|---|
APP_KEY |
Laravel app key — encrypts operational secrets. Losing it loses those secrets. |
APP_URL / FORCE_HTTPS |
Public URL; force https:// URLs behind a plain-HTTP reverse proxy. |
TRUSTED_PROXIES |
Reverse-proxy CIDRs (never *) so the real client IP reaches the audit log. |
SESSION_SECURE_COOKIE |
true behind TLS — also enables HSTS + COOP. |
CORS_ALLOWED_ORIGINS |
Allowed browser origins for a split-host SPA (default *). |
DB_* / REDIS_* |
PostgreSQL + Valkey. |
MAIL_* |
Outgoing mail for auth notifications (also configurable in-app). |
| Frontend build | VITE_API_URL (API origin, empty = same origin), VITE_BASE (base path), VITE_APP_VERSION (sidebar version). |
See .env.docker.example (deploy) and backend/.env.example (local backend) for the
complete, commented lists.
The REST API lives under /api/v1, authenticated with Sanctum bearer tokens
(abilities:device). The full, always-current contract is in
openapi.yaml; a CI check keeps it in lockstep with the routes. This is
the interface a Go/Python backend would implement and native mobile clients build
against.
MIT — see LICENSE.