
# Parkrr
**Self-hosted management for vehicle storage spaces** — vehicles, people and costs,
as a mobile-first PWA. Written in Go, backed by PostgreSQL, shipped entirely via Docker.
[](https://github.com/d0linger/Parkrr/actions/workflows/ci.yml)
[](https://github.com/d0linger/Parkrr/actions/workflows/golangci-lint.yml)
[](LICENSE)



Parkrr helps small operators (storage-space rental, winter storage, camping and
boat storage …) manage **vehicles** (cars, trailers, caravans, motorhomes,
motorcycles …) that **people** put into storage, and compute the **ongoing costs**
per vehicle — monthly or yearly, prorated to the day.
> All frontend assets (CSS, JS, icons, charts) are served **locally** — no external
> CDNs, no tracking, no cloud. Your data stays with you.
>
> The application UI is in **German**; this README is in English.
---
## Local previews
Screenshots, mockups and generated review reports are local-only. They are not
distributed through Git or Docker build contexts. Product icons and vehicle
artwork remain versioned; deterministic test fixtures remain part of the tests.
See [local artifact policy and Git guards](docs/local-artifacts.md) for checks,
hook installation, and the separate handling required for existing Git history.
---
## ✨ Features
- **People & vehicles** — multiple vehicles per person, a lean form, and detail
pages (deep-linkable) with balance, charts and history.
- **Slider controls** — set the storage and payment status per vehicle straight
from a slider (*reserved · stored · collected* and *open · paid*), one tap, no form.
- **Seasonal re-use** — *"↻ Store again"* duplicates a collected vehicle (type,
plate, price **and photos**) with a new start date.
- **Lifecycle & reservations** — status history (who/when/note); the end date is
set automatically on collection.
- **Garagenplaner (Stellplatzverwaltung)** — a to-scale floor-plan editor per
hall: draw walls, mark exclusion zones and place/rotate each vehicle on its
spot, with live fit checks (footprint inside the hall, height ≤ door, weight ≤
floor load) and a colour-coded occupancy bar. Import a **DXF or image** as a
plan underlay. Structured as garages → halls → spots.
- **Photos** — per vehicle (JPEG/PNG), validated and re-encoded on upload
(EXIF/GPS stripped), gallery with lightbox.
- **Handover protocols (Übergabeprotokoll)** — record the condition and a **drawn
signature** on check-in and on collection; download each protocol as a PDF.
- **Central tariffs** — vehicle types with default prices, **overridable per
vehicle** (special price); monthly/yearly price optionally **coupled**
(year = month × 12, one field fills the other automatically).
- **Per-person flat rate (Pauschale)** — when someone stores several vehicles and
agrees **one flat amount** (monthly or yearly) covering all of them instead of
per-vehicle billing: prorated to the day, with **paid status per year**
(open/paid); the vehicles then show as "covered by the flat rate".
- **Cost tracking** — day-accurate costs from the start date to the collection
date (or to today).
- **Extra & recurring charges** — one-off items (cleaning, winter service …) from
a service catalog, plus **recurring** charges (e.g. monthly electricity) that
bill automatically per completed period.
- **Invoicing (Rechnungen)** — issue period invoices (Austrian **BAO**-compliant,
immutable once issued — corrections are reversals, not silent edits), download
as **PDF**, attach a **payment QR code**, send **reminders**, and track overdue.
- **Payments & credit (Guthaben)** — record a payment, auto-allocate it across the
open items, and carry any remainder forward as credit; reversing a payment
re-opens exactly what it had covered.
- **Austrian tax-year ledger** — track cash-basis income and immutable expenses
with receipts, recurring costs, properties, straight-line depreciation and
E1b previews. Read-only by default: inspect existing records, simulate the
year boundary, and export adviser files without adopting a bookkeeping workflow.
Explicit opt-in enables manual tax entries and year closing; it does not
activate invoicing or import externally verified annual adjustments.
- **Statistics & charts** — revenue/month, status distribution, paid/open, and
cost per person by month and year (local SVG charts).
- **CSV import & export** — bulk-import people from a CSV (with a downloadable
example template), and export lists (people, vehicles, invoices, occupancy …) as CSV.
- **Fuzzy search** — one server-side command palette across people, vehicles,
invoices, garages, halls, tariffs and services: German stemming,
diacritics-folded (finds *Müller* for `muller`) and typo-tolerant.
- **Self-service portal** — a per-person magic link lets a customer see their
balance, download invoice PDFs and open a payment QR code — no login, no
account.
- **Backups** — scheduled, **encrypted** (AES-256-GCM) database dumps to a local
volume and optionally **off-site to S3** (any S3-compatible store), with a
restore path and a health check.
- **Multi-user & roles** — *Admin, Editor, Reader* (see [Roles & permissions](#-roles--permissions)).
- **Security** — passkeys (WebAuthn), 2FA (TOTP) + **recovery codes**, hashed
session tokens, rate limiting, hardened HTTP headers. See [Security](#-security).
- **PWA** — installable on a phone, light/dark, offline-capable app shell.
---
## 🚀 Quick start (Docker)
Requirements: Docker + Docker Compose.
```bash
git clone https://github.com/d0linger/Parkrr.git
cd Parkrr
# 1. Configuration
cp .env.example .env
# set at least, in .env:
# - PARKRR_ADMIN_PASSWORD
# - PARKRR_SESSION_SECRET (e.g.: openssl rand -base64 48)
# - PARKRR_DB_PASSWORD
# 2. Start
docker compose up -d --build
# 3. Open: http://localhost:8080
# Log in with PARKRR_ADMIN_USERNAME / PARKRR_ADMIN_PASSWORD
```
The database runs as a standalone Postgres container (reachable only on the
compose network). Schema migrations run automatically at startup.
---
## ⚙️ Configuration
| Variable | Description | Default |
| --- | --- | --- |
| `PARKRR_LISTEN_ADDR` | HTTP listen address | `:8080` |
| `PARKRR_HTTP_PORT` | published host port (compose) | `8080` |
| `PARKRR_DATABASE_URL` | full Postgres URL (overrides the discrete values) | – |
| `PARKRR_DB_HOST` / `PARKRR_DB_PORT` | DB host / port | `db` / `5432` |
| `PARKRR_DB_USER` / `PARKRR_DB_NAME` | DB user / database name | `parkrr` |
| `PARKRR_DB_PASSWORD` | **required** (compose); database password | - |
| `PARKRR_DB_SSLMODE` | `disable` for bundled internal DB; use `verify-full` and a trusted CA for an external database | `disable` |
| `PARKRR_ADMIN_USERNAME` | admin username | `admin` |
| `PARKRR_ADMIN_EMAIL` | admin email | `admin@example.com` |
| `PARKRR_ADMIN_PASSWORD` | **required**; 8-72 bytes when applied; public placeholders rejected | - |
| `PARKRR_SESSION_SECRET` | **required**; at least 32 bytes; public placeholders rejected | - |
| `PARKRR_SESSION_MAX_AGE` | session lifetime / inactivity window (sec.) | `604800` (7 days) |
| `PARKRR_SESSION_SLIDING` | extend the session on activity (re-login only after inactivity) | `false` |
| `PARKRR_SESSION_ABSOLUTE_MAX_AGE` | hard cap on session lifetime (sec., when sliding) | `7776000` (90 days) |
| `PARKRR_WEBAUTHN_RP_ID` | enable passkeys: registrable domain (no scheme/port) | – (off) |
| `PARKRR_WEBAUTHN_RP_NAME` | display name shown by the authenticator | `Parkrr` |
| `PARKRR_WEBAUTHN_ORIGINS` | allowed origins, e.g. `https://parkrr.example.com` | – |
| `PARKRR_SECURE_COOKIES` | `Secure` flag on cookies (secure-by-default; `false` only for plain-HTTP dev on non-`localhost`) | `true` |
| `PARKRR_TRUSTED_PROXY` | behind a reverse proxy: trust `X-Forwarded-*` | `false` |
| `PARKRR_TRUSTED_PROXY_CIDRS` | **required when `PARKRR_TRUSTED_PROXY=true`** — comma-separated proxy IP range(s); `X-Forwarded-*` is honoured only from these peers (startup fails if trusted-proxy is on and this is empty) | – |
| `PARKRR_PUBLIC_BASE_URL` | external base URL (self-service portal links, email) | – |
| `PARKRR_RATE_LIMIT_PER_MIN` | general per-IP request budget/minute (`0` = off) | `600` |
| `PARKRR_AUDIT_RETENTION_DAYS` | long window: prune audit entries older than N days (`0` = disables **this** window only, see below). Records of account (`invoice`, `payment`, `billing`, `flatrate`, `recurring_charge`) are never pruned at any age | `2555` (7 y, BAO §132) |
| `PARKRR_AUDIT_RETENTION_SHORT_DAYS` | short window for auth/ops noise only (`login`, `logout`, `backup`, `remind`, `import`); `0` disables the short tier | `365` |
| `PARKRR_METRICS_TOKEN` | Bearer token for `/metrics`. Empty = `/metrics` is open (internal network only) — unless `PARKRR_METRICS_REQUIRE_AUTH=true`, then it is disabled | – |
| `PARKRR_METRICS_REQUIRE_AUTH` | with no token set, disable `/metrics` instead of serving it openly. GHCR deployments (`docker-compose.ghcr.yml`) default to `true`, so they must set `PARKRR_METRICS_TOKEN` to enable metrics | `false` in the app; `true` in `docker-compose.ghcr.yml` |
| `PARKRR_CHECK_BREACHED_PASSWORDS` | check new passwords against the HIBP range API (fail-open) | `true` |
| `PARKRR_CHECK_BREACHED_BOOTSTRAP_PASSWORDS` | explicit opt-in for HIBP when creating or force-resetting the ENV admin password; also requires the preceding switch; sends a five-character SHA-1 prefix; ordinary restarts do no lookup | `false` |
| `PARKRR_BREACH_CHECK_FAIL_CLOSED` | reject a new password if the HIBP check itself fails (instead of the default fail-open) | `false` |
| `PARKRR_LOG_FORMAT` / `PARKRR_LOG_LEVEL` | `json`\|`text` / `debug`..`error` | `json` / `info` |
> The admin account is created/updated from the ENV values on **every start** —
> the ENV remains the source of truth for the admin.
**Backups & email** (all optional — a feature stays off until its key variable is set):
| Variable | Description | Default |
| --- | --- | --- |
| `PARKRR_BACKUP_KEY` | passphrase enabling scheduled **encrypted** DB backups (AES-256-GCM); empty = backups off | – |
| `PARKRR_BACKUP_DIR` | directory for local dumps (a mounted volume). Backup, S3 and restore temp files go to its `.tmp/` subfolder; keep about 2× the archive size free there. Set it to a writable volume even for S3-only setups | `/backups` (compose) |
| `PARKRR_BROWSER_RESTORE` | opt in to coordinated browser restore; drains all replicas, restores under an exclusive DB lease, migrates, verifies, and revokes sessions | `false` |
| `PARKRR_S3_ENDPOINT` / `PARKRR_S3_REGION` | off-site target: S3-compatible endpoint / region | – |
| `PARKRR_S3_BUCKET` / `PARKRR_S3_PREFIX` | bucket and key prefix for uploaded backups | – |
| `PARKRR_S3_ACCESS_KEY` / `PARKRR_S3_SECRET_KEY` | S3 credentials | – |
| `PARKRR_S3_USE_SSL` | use TLS to the S3 endpoint | `true` |
| `PARKRR_SMTP_HOST` / `PARKRR_SMTP_PORT` | SMTP server for payment reminders & portal links; empty = email off | – / `587` |
| `PARKRR_SMTP_USERNAME` / `PARKRR_SMTP_PASSWORD` | SMTP credentials | – |
| `PARKRR_SMTP_FROM` / `PARKRR_SMTP_FROM_NAME` | envelope/display sender | – |
| `PARKRR_SMTP_TLS` | `starttls` \| `tls` \| `none` | `starttls` |
| `PARKRR_ALERT_EMAIL` | recipients for operational alerts (failed scheduled backup); comma/space separated, empty = off | – |
| `PARKRR_TIMEZONE` | business time zone (IANA name, e.g. `Europe/Vienna`) deciding what "today" and "this month" mean; empty = container zone | – |
| `PARKRR_REQUIRE_2FA` | require verified TOTP/recovery-code or passkey proof for each session; unenrolled accounts can only reach setup, and existing unverified sessions must sign in again | `false` |
| `PARKRR_PASSKEY_ONLY` | disable password login entirely (passkey sign-in only); requires `PARKRR_WEBAUTHN_RP_ID` | `false` |
| `PARKRR_AUTO_INVOICE_CRON` | 5-field cron for the automatic invoice run (e.g. `0 6 1 * *`); empty = off; uses the same path as the manual button, billed periods are never invoiced twice | – |
| `PARKRR_TAX_WRITES_ENABLED` | allow manual tax-book changes, receipt uploads, tax settings, payment tax metadata and year locking/unlocking; `false` keeps tax reports, simulations, receipts and exports read-only | `false` |
| `PARKRR_TAX_EXPORT_MAX_RECEIPT_MB` | total uncompressed receipt bytes staged per tax ZIP export, integer 1–1024; a larger value requires sufficient private `TMPDIR` capacity | `24` |
Restoring requires patched `pg_restore` and `psql` clients: PostgreSQL 16.15+ for the supported server-16 deployment. CLI installations using majors 14/15/17/18 need at least 14.24/15.19/17.11/18.6 respectively. Unsupported or prerelease clients are rejected before any restore SQL runs (CVE-2025-8714, CVE-2026-18408).
Tax-book activation is optional: set `PARKRR_TAX_WRITES_ENABLED=true` and restart
the application (recreate the container to apply changed environment variables).
Use the same setting on every app replica. Activation does not book anything,
issue invoices, import annual corrections, or recalculate agreed prices.
Returning to `false` blocks further tax changes, but preserves existing expenses,
depreciation, assignments and closed-year locks. Those locks continue protecting
ordinary payment changes; unlocking requires explicit tax-write activation.
The normal booking/payment workflow does not require using the tax module.
E1b remains a preview, not a verified tax filing. See the
[operator handbook](docs/betreiber-handbuch.md#steuerbereich-zuerst-nur-lesen-später-aktivieren).
The optional 2025/2026 tax-review workbench compares explicitly supplied turnover
and individual income/expense year-boundary scenarios. It works in read-only
mode, never stores its input or changes ledger totals, and can download a local
JSON draft. A source/reviewer label is not professional verification. There is
no draft import or automatic adoption into the tax book.
A separate annual-value import preview accepts bounded UTF-8 JSON files for
2025/2026 with explicit object, source and reviewer labels. It validates exact
signed correction amounts and non-negative external depreciation, rejects
ambiguous/duplicate fields and IDs within the file, and shows totals separately.
It does not verify provenance or match existing entries, persist imports, change
tax totals or provide complete E1b mapping. Admin and CSRF protection still apply
in read-only mode; repeat previews are stateless. The downloadable example is
synthetic. See the handbook's import-preview section for the file contract.
Tax ZIP exports materialize all database content in one read-only snapshot,
including receipt bytes, then release the database connection before streaming.
Receipts use a private temporary file, removed on completion or failure. The
hardened deployment has a 32 MiB `/tmp`; the default 24 MiB receipt limit leaves
headroom, but concurrent exports can still exhaust it and fail without changing
data. Provision a private volume and set `TMPDIR` before increasing the limit.
Existing automatically recorded flat-rate/recurring period receipts now block
destructive reset/replacement/source deletion with HTTP 409. New individual
period payments remain possible; a full reversal/rebooking workflow is pending
a reviewed schema change. Tariff edits likewise reject unresolved legacy price
dependencies instead of silently changing existing booking amounts. See the
operator handbook before upgrading.
---
## 🔐 Passkeys (WebAuthn)
Passwordless, phishing-resistant sign-in via fingerprint/face recognition. Enabled
as soon as `PARKRR_WEBAUTHN_RP_ID` is set (requires HTTPS, e.g. via your proxy —
`http://localhost` also works, as browsers treat it as a secure context):
```env
PARKRR_WEBAUTHN_RP_ID=parkrr.example.com
PARKRR_WEBAUTHN_ORIGINS=https://parkrr.example.com
```
- **Enroll:** **Settings → Passkeys → "+ Passkey hinzufügen"** (after a normal login).
- **Sign in:** the **"Mit Passkey anmelden"** button on the login screen —
usernameless (the passkey carries your account handle).
- Password + TOTP + recovery codes remain as the fallback / recovery path.
- The server stores only **public keys** — no secret material; the private key
never leaves the device. A passkey is itself MFA, so TOTP is **not** additionally
requested on a passkey login.
> **Where do I configure passkeys?** It's a server-side switch: passkeys are off
> until `PARKRR_WEBAUTHN_RP_ID` is set. Once it is, the **Passkeys** card appears
> in Settings and the passkey button appears on the login screen. `RP_ID` and
> `ORIGINS` must match the exact URL you open Parkrr at.
---
## 📈 Health, readiness & metrics
| Endpoint | Purpose | Auth |
|----------|---------|------|
| `GET /healthz` | liveness (process up, version) | open |
| `GET /readyz` | readiness (DB reachable) — `503` if not | open |
| `GET /metrics` | Prometheus (request count/latency, DB pool) | `PARKRR_METRICS_TOKEN` (Bearer), if set |
The container ships a `HEALTHCHECK` (`parkrr healthcheck`, self-probing — the
image ships no curl/wget). The **audit log is append-only** (a DB
trigger blocks UPDATE/DELETE); only the retention job
(`PARKRR_AUDIT_RETENTION_DAYS` / `PARKRR_AUDIT_RETENTION_SHORT_DAYS`) may remove
old entries. The two windows are independent: setting the long one to `0` keeps
the change trail forever but does **not** stop the short tier from ageing out
login/logout noise — set both to `0` to disable retention completely.
---
### Beispiel-Alerts & Dashboard
`ops/prometheus-alerts.yml` bringt Startregeln für Prometheus mit (Erreichbarkeit,
5xx-Quote, p95-Latenz, DB-Pool-Sättigung, Neustart-Schleife), `ops/grafana-dashboard.json`
ein importierbares Grafana-Dashboard über dieselben Metriken. Beide sind auf die
tatsächlich exportierten `parkrr_*`-Metriken zugeschnitten — Schwellwerte sind
Startwerte, Nachschärfen gehört zum Betrieb.
---
## 🌐 Running behind a reverse proxy (Nginx Proxy Manager, Traefik, Caddy …)
Parkrr uses only **relative paths** and listens on `:8080` — it runs behind a
reverse proxy on its own (sub)domain without changes. TLS is terminated at the proxy.
**1. Adjust `.env`:**
```env
PARKRR_TRUSTED_PROXY=true # trust X-Forwarded-For/-Proto (behind a proxy only!)
PARKRR_TRUSTED_PROXY_CIDRS=172.16.0.0/12 # required when the above is true: the proxy's IP range(s)
PARKRR_SECURE_COOKIES=true # default; correct behind TLS/proxy (use false only for plain-HTTP dev)
```
With `PARKRR_TRUSTED_PROXY=true`, Parkrr uses the real client IP from
`X-Forwarded-For`/`X-Real-IP` (for logs, rate limiting, audit) and detects HTTPS
via `X-Forwarded-Proto` (→ `Secure` cookies + HSTS). `X-Forwarded-*` headers are
honoured **only** from a peer inside `PARKRR_TRUSTED_PROXY_CIDRS`, so this
allowlist is **required** whenever the trusted-proxy switch is on — the app
**refuses to start** without it (otherwise any direct client could spoof its own
IP). Set it to your proxy's address range (e.g. the Docker bridge subnet). **Without**
a proxy, keep `PARKRR_TRUSTED_PROXY=false`.
**2. Nginx Proxy Manager — Proxy Host:**
- **Scheme** `http` · **Forward Hostname** `parkrr-app` (on a shared Docker network)
or the host IP · **Forward Port** `8080` · **Websockets** not needed
- **SSL** tab: Let's Encrypt certificate, enable *Force SSL* + *HTTP/2*.
NPM sets `X-Forwarded-Proto`/`X-Forwarded-For` automatically.
For direct access on the Docker network, put both containers on the same network
and remove the `ports:` block of the `app` service in `docker-compose.yml`.
**3. Generic Nginx example:**
```nginx
server {
listen 443 ssl http2;
server_name parkrr.example.com;
# ssl_certificate ...; ssl_certificate_key ...;
client_max_body_size 10m; # photo uploads (up to 8 MB)
location / {
proxy_pass http://parkrr-app:8080;
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;
}
}
```
---
## 🛡️ Rootless & hardened deployment
Parkrr runs cleanly under a **rootless engine** (rootless Docker or Podman). It
needs no privileged features, no host networking, and no bind mounts: the app
image already runs as a **non-root** user (the `parkrr` user, uid 10001),
photos live in the database, and the DB uses a **named volume**. The published
port (`${PARKRR_HTTP_PORT:-8080}:8080`) is ≥ 1024, so no extra capability is
required to bind it.
**Rootless Docker (Ubuntu):**
```bash
sudo apt install -y uidmap dbus-user-session
dockerd-rootless-setuptool.sh install # run as your non-root user
loginctl enable-linger "$USER" # keep containers up after logout
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/docker.sock
docker compose up -d # compose file is unchanged
```
**Podman (rootless by default):** `podman compose up -d` — no daemon, no setup tool.
**Portainer:** point the endpoint at the rootless socket
(`/run/user/