Ctrl K
ComunidadGuías…

README.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<p align="center"><img src="static/brand/kept-lockup.svg" alt="Kept" height="64"></p>

<p align="center"><a href="https://orellbuehler.github.io/kept/"><strong>Product page →</strong></a></p>

Self-hosted personal finance. Import your accounts, match bills to payments and keep a balance
history — on your own server, in a single SQLite file by default (PostgreSQL optional).

## Features

- **Accounts** grouped by institution: current, savings, credit card, investment, pension, cash.
  Accounts you don't import can be tracked with manual balance snapshots.
- **Shared accounts**: set "my share" (e.g. 50% or 33.33%) on an account you co-own. Imports and
  transactions keep their full amounts; a "My share" view on the dashboard, net worth chart and
  report, budgets and category totals counts them at your share (the co-owner needs no account).
- **Statement import** from ISO 20022 camt.053 statements and camt.054 notifications
  (`.001.02`/`.04`/`.08` and later), from SWIFT MT940 (UTF-8 or Latin-1, structured `:86:` and
  SEPA keywords) and from any CSV or Excel export through a column mapping you set up once per
  account, with a live preview.
  Re-importing overlapping files never duplicates transactions; Kept warns when a file's opening
  balance doesn't continue the account's history. Every import can be undone.
- **Bills**: upload a PDF and Kept reads the Swiss QR-bill (falls back to the text when there is
  no QR code), or enter a bill by hand. Payments are matched by structured reference
  automatically, by IBAN and amount as a suggestion, or manually — including bills paid in parts,
  overpayments and credit notes awaiting a refund.
- **Categories and budgets**: your own income and expense categories (one level of
  subcategories), rules that categorize transactions on import (counterparty, description, IBAN,
  direction) and can be re-run on uncategorized ones, a category you set by hand always wins, and
  monthly budgets per category showing spent against budget, per currency.
- **Dashboard**: net worth over time per currency (no currency conversion), this month's income
  and expenses, overdue and upcoming bills, and accounts whose last import is getting old.
- **Taxes**: mark transactions and bills with a tax year, enter the lines from the tax office's
  statement and the assessed total, and see per year what you paid against what they counted —
  missing lines, amount differences and the remaining balance (amount due or refund).
- **PDF reports**: account statement, open bills, net worth, tax reconciliation.
- **Paperless-ngx (optional)**: pull bills from a tag or saved view, write amount, due date,
  reference and status back to custom fields, and file reports into Paperless.
- **Notifications**: per user, choose triggers (bill due within N days, bill overdue, monthly
  budget reaching X%, account not imported for N days) and channels (ntfy, signed webhook, and
  email for administrators). Checked hourly; each event is sent once. Set up under Settings →
  Notifications.
- **External API (optional)**: token-authenticated read access to bills, transactions, categories
  and recurring payments for companion apps you run, plus links they can attach to bills and
  transactions. Tokens are scoped, can be limited to categories, expire and are revocable. Set up
  under Settings → API tokens.
- **Installable**: add Kept to your home screen or desktop from the browser. Only the app's own
  static files are cached on the device, so nothing from your accounts is stored there.
- Local users with an admin who creates the others; every user only sees their own data.
- Optional two-factor authentication per user: authenticator app (TOTP) and passkeys.

eBill invoices can't be received directly by a self-hosted app. Download the invoice PDF in your
e-banking and upload it as a bill — the QR data is read from it.

## Run it

```bash
docker run -d --name kept -p 3000:3000 -v kept-data:/data \
  -e KEPT_SECRET_KEY="$(openssl rand -base64 32)" \
  -e ORIGIN=https://kept.example.org \
  ghcr.io/orellbuehler/kept:latest
```

Open the app and create the first user at `/setup` — it becomes the administrator. Until that
happens, anyone who can reach the server can claim it, so do this right after starting it.

| Variable                               | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEPT_SECRET_KEY`                      | Required. 32 random bytes, base64 (`openssl rand -base64 32`). Encrypts stored integration tokens; keep it with your backups — changing it locks them. Kept refuses to start without a valid key (the image and `bun run start` alike). Only `bun dev` and the tests fall back to a publicly known development key; for throwaway local use of a built server you can opt in with `KEPT_ALLOW_INSECURE_KEY=true`, never with real data.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `ORIGIN`                               | The public URL. Required behind a reverse proxy, otherwise form posts are rejected. Passkeys are bound to its host name (see Two-factor).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `ADDRESS_HEADER`, `XFF_DEPTH`          | Client address behind a proxy (e.g. `X-Forwarded-For`, `1`) so login rate limiting works per client. Behind a reverse proxy, set it: without it every client appears to come from the proxy address and share one failed-login budget (Kept logs a warning at startup, and again when it sees proxy headers). Login throttling is per username and client address and per address (IPv6 clients count as their /64 prefix; IPv4-mapped addresses as the IPv4 address). A username on its own is never locked, but it is throttled: after 10 failures from any addresses within 15 minutes, further attempts on it run one at a time, each after a growing backoff (up to 5 seconds); at most 3 may be waiting per username and 200 overall, and the excess is refused at once with the normal "too many attempts" message. The owner with the correct password therefore gets in after a bounded wait unless their own client is over budget. A client address that completed a full login (second factor included) as that user within the last 30 days skips this queue and backoff, but still counts against the per-username-and-address and per-address limits; clients that merely have no failures of their own get no such bypass. Residual risk: that memory is in process only (bounded, oldest evicted first) and is lost on restart, and the owner on a new address, or after a restart, waits in the queue like anyone else, so a sustained distributed attack can delay them (never lock them out). A value that is not a valid IP address (after removing brackets and port) is treated as one shared key, the same as a missing address. **Only set `ADDRESS_HEADER` when Kept runs behind a proxy that overwrites that header** (never appends to or forwards a client-supplied one); otherwise clients can pick their own rate-limit key. |
| `KEPT_COOKIE_SECURE=false`             | Only when serving over plain HTTP on a trusted network. Any other value keeps session cookies `Secure`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `PORT`, `HOST`, `BODY_SIZE_LIMIT`      | Read by the SvelteKit server. The image sets `3000`, `0.0.0.0` and `25M`; outside the image the adapter defaults are `3000`, `0.0.0.0` and `512K`. `PROTOCOL_HEADER`, `HOST_HEADER`, `PORT_HEADER`, `SOCKET_PATH` and `IDLE_TIMEOUT` are also read by the adapter, see its documentation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `DATABASE_URL`                         | Optional. A `postgres://` or `postgresql://` URL (with a host) selects PostgreSQL, see [Choosing a backend](#choosing-a-backend). Unset or empty: SQLite at `DATABASE_PATH`. Any other value stops Kept at startup instead of silently creating an empty SQLite file. Contains the password: keep it out of logs and bug reports. TLS is requested in the URL (`?sslmode=require`, the driver default is no TLS).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `DATABASE_PATH`                        | SQLite only (ignored with `DATABASE_URL`). Defaults to `/data/kept.db` in the image, `./data/kept.db` otherwise. With local file storage and no `KEPT_STORAGE_DIR`, uploaded bills and pending imports live next to it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `KEPT_DB_POOL_MAX`                     | PostgreSQL only. Connections in the pool, 1 to 500. Defaults to `10`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `KEPT_DB_STATEMENT_TIMEOUT_MS`         | PostgreSQL only. Longest a single statement may run, in milliseconds (digits only, at most 86400000). `0` disables the limit. Defaults to `30000`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `KEPT_DB_TRANSACTION_TIMEOUT_MS`       | PostgreSQL only. Longest a transaction may stay open, same format. `0` disables the limit. Defaults to `60000`. The server setting it needs exists from PostgreSQL 17; on an older server Kept stops at startup unless this is `0`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `KEPT_DB_APPLICATION_NAME`             | PostgreSQL only. Name shown in `pg_stat_activity`: 1 to 63 characters of `A-Za-z0-9_.:@/-`. Defaults to `kept`; the migration connection adds `-migrate`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `KEPT_DB_PREPARE`                      | PostgreSQL only. `false` turns off prepared statements, required behind PgBouncer in transaction pooling mode. `true` or `false`, defaults to `true`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `KEPT_STORAGE_DIR`                     | Optional, local file storage only. Where uploaded files are stored: bills in a `documents` subfolder and unconfirmed uploads in `pending-imports`. Defaults to the directory of `DATABASE_PATH`; required with PostgreSQL unless `KEPT_STORAGE=s3`. Setting it on an existing install does not move `documents/`: move the folder yourself first. Unconfirmed uploads in the old `pending-imports/` folder next to the database can be deleted.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `KEPT_STORAGE`                         | Optional. `fs` (default, local files) or `s3` (an S3-compatible bucket, configured with the `KEPT_S3_*` variables in [S3 storage](#s3-storage)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `KEPT_S3_*`                            | `KEPT_S3_BUCKET`, `KEPT_S3_ACCESS_KEY_ID`, `KEPT_S3_SECRET_ACCESS_KEY`, `KEPT_S3_ENDPOINT`, `KEPT_S3_REGION`, `KEPT_S3_PREFIX` and `KEPT_S3_VIRTUAL_HOSTED_STYLE`, only read with `KEPT_STORAGE=s3`. Listed in [S3 storage](#s3-storage).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `KEPT_BACKUP_DIR`                      | Optional, SQLite only. Writes a daily database backup into this directory (e.g. `/data/backups`). Ignored with PostgreSQL (logged at startup).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `KEPT_BACKUP_KEEP`                     | SQLite only. How many scheduled backups to keep (1 to 3650); older ones are deleted. Defaults to `7`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `KEPT_INBOX_DIR`                       | Optional. Enables the watch-folder import, e.g. `/data/inbox` (see below).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `KEPT_INBOX_INTERVAL`                  | Seconds between inbox scans (5 to 86400). Defaults to `60`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `KEPT_SMTP_HOST`                       | Optional. SMTP server for the email notification channel, which only administrators can use; the channel is only offered when this and `KEPT_SMTP_FROM` are set.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `KEPT_SMTP_FROM`                       | Sender address for notification emails, e.g. `Kept <kept@example.org>`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `KEPT_SMTP_PORT`                       | 1 to 65535. Defaults to `587` (STARTTLS when offered).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `KEPT_SMTP_SECURE`                     | `true` for implicit TLS, `false` otherwise. Defaults to `true` on port 465, else `false`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `KEPT_ALLOW_PRIVATE_NETWORK`           | Optional. `true` lets every user point webhooks, ntfy and Paperless at private, loopback, link-local and metadata addresses (including names that resolve to them). By default only administrators may, so a member cannot make the server reach your internal network; members with a Paperless on the internal network need this, or an administrator to set it up.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `KEPT_NOTIFY_BLOCK_PRIVATE`            | Optional. `true` blocks those destinations for everyone, administrators included, and wins over `KEPT_ALLOW_PRIVATE_NETWORK`. Checked at save and at send time; the connection goes to the address that was checked, so a changing DNS answer cannot redirect it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `KEPT_SMTP_USER`, `KEPT_SMTP_PASSWORD` | Optional SMTP login.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |

### Choosing a backend

Kept stores its data in two places: the **database** and **file storage** (uploaded bill PDFs and unconfirmed
imports). Each has two options, chosen independently.

|              | Default                                                   | Alternative                                         |
| ------------ | --------------------------------------------------------- | --------------------------------------------------- |
| Database     | SQLite, one file at `DATABASE_PATH`                       | PostgreSQL 17 or newer, selected by `DATABASE_URL`  |
| File storage | Local files in `KEPT_STORAGE_DIR` or next to the database | S3-compatible bucket, selected by `KEPT_STORAGE=s3` |

**SQLite with local files is the default and the recommended setup for a single household.** It needs nothing
besides the container and one volume, and Kept's built-in backups cover it. Choose PostgreSQL when you
already run a server you back up and monitor, want a managed database, or have a larger installation with
several people using it at once. Choose S3 when the container should keep no state of its own.

**PostgreSQL.** Create an empty database and a role that owns it, then point Kept at it:

```bash
docker run -d --name kept -p 3000:3000 \
  -e KEPT_SECRET_KEY="$(openssl rand -base64 32)" \
  -e ORIGIN=https://kept.example.org \
  -e DATABASE_URL=postgres://kept:PASSWORD@db.example.org:5432/kept \
  -e KEPT_STORAGE_DIR=/data/files -v kept-data:/data \
  ghcr.io/orellbuehler/kept:latest
```

- Kept applies its migrations on startup and keeps their history in a `drizzle` schema, so the role needs
  to create that schema (CREATE on the database) and tables in `public`; owning the database is enough.
  Concurrent starts take turns: a transaction-scoped advisory lock serialises them, and a start waits at most
  300 seconds for another instance's migrations before failing with a clear error.
- PostgreSQL 17 or newer is required. On an older server set `KEPT_DB_TRANSACTION_TIMEOUT_MS=0`
  (the setting is not known before 17); Kept stops at startup with that hint otherwise.
- With a URL, `DATABASE_PATH` is ignored and there is no data directory to default file storage to, so set
  `KEPT_STORAGE_DIR` or use S3.
- Behind PgBouncer in transaction pooling mode set `KEPT_DB_PREPARE=false`. PgBouncer also rejects the
  `statement_timeout` and `transaction_timeout` startup parameters Kept sends: either list them in its
  `ignore_startup_parameters`, or set `KEPT_DB_STATEMENT_TIMEOUT_MS=0` and `KEPT_DB_TRANSACTION_TIMEOUT_MS=0`.
- The pool, timeouts and application name are tuned with `KEPT_DB_POOL_MAX` (10), `KEPT_DB_STATEMENT_TIMEOUT_MS`
  (30000), `KEPT_DB_TRANSACTION_TIMEOUT_MS` (60000) and `KEPT_DB_APPLICATION_NAME` (`kept`), see the table
  above. A request waiting for a pooled connection gives up after `KEPT_DB_STATEMENT_TIMEOUT_MS` (30 seconds when
  that is `0`) and fails with a "database is busy" conflict. `KEPT_DB_TRANSACTION_TIMEOUT_MS=0` disables the
  transaction limit entirely. Invalid values stop Kept at startup with a message that names the variable, never its value.
- Names sort in the order of the server's collation, so lists ordered by name can differ from SQLite
  (which compares bytes): a database with `en_US.utf8` ignores case and punctuation and sorts accented
  letters next to their base letter, while the `C` collation sorts by byte value and puts every uppercase
  letter before every lowercase one. Choose the collation when you create the database; Kept does not
  depend on a particular one.

**Several instances.** More than one Kept container can share one PostgreSQL database (never SQLite, which is
one process on one file). Migrations take turns, and every scheduled job (Paperless sync, market data refresh,
watch-folder scan, notification check) takes a PostgreSQL advisory lock for each run, so a tick runs on only one
instance at a time; the others skip it. Some state is per process, though:

- **Use sticky sessions** at the load balancer. Sessions themselves live in the database, but the
  administrator's one-time download tokens are kept in memory by the instance that issued them, and the
  request that uses a token must reach the same instance (a token lives 60 seconds).
- **Rate limits apply per replica.** The login, second-factor, passkey, setup and administrator-action
  limiters and the Paperless webhook counters and failure memory are in memory, so with N instances an attacker gets N times the
  attempts, and a restart clears them. Put a rate limit in front of Kept as well if that matters to you.
- The watch folder is scanned by one instance at a time, so every instance must see the same folder
  (a shared volume); a folder that only one instance can see is scanned only while that instance holds the lock.
- Daily backups copy the SQLite file and are not available with PostgreSQL; back the database up with
  `pg_dump` or a managed snapshot.
- On `SIGTERM` or `SIGINT` an instance stops its schedulers, then waits up to ten seconds (in total) for
  requests that are still being handled and for background work that is already running, closes its
  database connections and exits, so rolling deploys leave no lock behind (the server also releases a lock
  when a connection drops). Responses still streaming to a client are not waited for.
- Each running job holds one extra server connection for the length of its run to keep its lock. Behind
  PgBouncer in transaction mode that is one pinned server connection per running job (at most four per
  instance), and the connection is exempt from `idle_in_transaction_session_timeout`.

**S3.** See [S3 storage](#s3-storage) for the variables, the bucket and the permissions it needs. It works with
either database. [`examples/docker-compose.postgres-s3.yml`](examples/docker-compose.postgres-s3.yml) runs
Kept with PostgreSQL and an S3-compatible store.

**There are no migration tools between backends yet.** Switching from SQLite to PostgreSQL, or from local files
to S3, starts a new empty install; moving local files into a bucket is described under S3 storage. Decide
before you collect data you would have to re-import.

### S3 storage

By default uploaded files live on disk. Set `KEPT_STORAGE=s3` to keep them in an S3-compatible bucket
instead (AWS S3, MinIO, Garage, SeaweedFS, Cloudflare R2 and similar). The database stays where it is, and
the watch folder and scheduled backups are always local.

| Variable                                             | Meaning                                                                                                                                                    |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KEPT_S3_BUCKET`                                     | Required. The bucket must already exist.                                                                                                                   |
| `KEPT_S3_ACCESS_KEY_ID`, `KEPT_S3_SECRET_ACCESS_KEY` | Required. Kept ignores the ambient `AWS_*` and `S3_*` variables, so nothing is picked up by accident. Instance roles and session tokens are not supported. |
| `KEPT_S3_ENDPOINT`                                   | Service URL without the bucket, e.g. `http://seaweedfs:8333`. Leave unset for AWS S3.                                                                      |
| `KEPT_S3_REGION`                                     | Defaults to `us-east-1`. Set it to the bucket's region on AWS; most other services accept the default.                                                     |
| `KEPT_S3_PREFIX`                                     | Optional key prefix inside the bucket, e.g. `kept`, to share a bucket. Changing it hides existing files.                                                   |
| `KEPT_S3_VIRTUAL_HOSTED_STYLE`                       | `true` or `false`. Defaults to `false`.                                                                                                                    |

Invalid or missing values stop Kept at startup with a message that names the variable, never its value.
Kept does not contact the bucket at startup; a wrong endpoint or credentials show up as errors when a file
is first stored or opened (requests give up after 60 seconds, uploads get 10 more seconds per MiB, up to 15 minutes).

**Path-style or virtual-hosted.** Path-style puts the bucket in the URL path (`http://host:9000/bucket/key`)
and is the default because MinIO, Garage and SeaweedFS use it. AWS S3 and Cloudflare R2 use virtual-hosted
style (`https://bucket.host/key`): set `KEPT_S3_VIRTUAL_HOSTED_STYLE=true`. `KEPT_S3_ENDPOINT` is always the service endpoint without the bucket
(`https://s3.eu-west-1.amazonaws.com`); Kept always puts `bucket.` in front of its host, or infers the AWS
endpoint from the region when no endpoint is set.

**Permissions.** The credentials need only these actions, and only for the bucket and the prefix:
`s3:GetObject`, `s3:PutObject` and `s3:DeleteObject` on `arn:aws:s3:::BUCKET/PREFIX/*`, plus
`s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts` on the same resource. Bun uploads bodies larger than
its part size (5 MiB by default) as multipart uploads and aborts them on failure, so large files may need
these two. Also grant `s3:ListBucket` on `arn:aws:s3:::BUCKET`, which Kept uses to list unconfirmed uploads (the `pending-imports/` keys).

Recommended: give `s3:ListBucket` on the bucket **without a condition**. AWS decides between a 404 and a 403
for a missing key by checking `s3:ListBucket`, and that check carries no prefix context, so a prefix condition
can turn "file not found" into "access denied". You can restrict listing with a `StringLike` condition on
`s3:prefix` (`PREFIX/*`, not "equal"), but then a missing file surfaces as an error (`S3 get failed
(... [AccessDenied])`) instead of "no file". Kept never treats a 403 as "missing". Leave the bucket private;
files are only ever served through Kept's own access checks, never by public or presigned URLs.

**Existing files are not migrated.** Switching a running install from local files to S3 does not copy
`documents/`: bills uploaded before the switch will show no file until you copy the folder into the bucket
under the same keys (for example `aws s3 sync /data/documents s3://BUCKET/PREFIX/documents`; omit `PREFIX/` if unset) yourself.
Back up the bucket with your storage provider; Kept's backups cover the database only.

### Watch-folder import

Set `KEPT_INBOX_DIR` and Kept scans it on start and every `KEPT_INBOX_INTERVAL` seconds. Every
user has a folder named after their username (created on the first scan). The process needs
write access to the directory.

```
<inbox>/<username>/statement.xml        camt.053/.054, MT940 (.sta .mt940 .txt): account found by the IBAN
<inbox>/<username>/<account>/export.csv CSV or Excel: account named by the folder
<inbox>/<username>/processed/           imported (or already known) files
<inbox>/<username>/review/              files waiting for you on the Import page
<inbox>/<username>/failed/              rejected files, each with a `.reason.txt`
```

- The folder `<account>` is the account's name (case-insensitive) or its IBAN. CSV and Excel
  files also need the column mapping saved for that account (map one file by hand once); without
  it the file waits in `review/`. A camt or MT940 file placed in an account folder is imported
  into that account, and the usual IBAN check still applies.
- Files go through the same preview and confirm path as uploads: duplicates are skipped, and the
  import shows up in history and can be undone. A file is imported automatically only when the
  account is unambiguous and the preview has no warnings (for example a balance gap). Otherwise
  it waits in `review/`; open the Import page and press **Review** to continue as a normal upload.
- A file is never processed twice: files are identified by SHA-256. A repeat is moved to
  `processed/`. A file from `failed/` is retried when you drop it in again.
- Files modified in the last 10 seconds, hidden files and other extensions (`.part`, `.pdf`) are
  left alone. Supported: `.xml`, `.csv`, `.txt`, `.xlsx`, up to 20 MB.
- The Import page shows the last scan and recent auto-imports, reviews and failures.

### Backup and restore

A Kept install has up to three things to back up, and what covers which depends on the backend (see
[Choosing a backend](#choosing-a-backend)):

| What              | SQLite                                  | PostgreSQL                                             |
| ----------------- | --------------------------------------- | ------------------------------------------------------ |
| Database          | Built-in download and `KEPT_BACKUP_DIR` | `pg_dump`, or your provider's snapshots                |
| Uploaded files    | The `/data` volume, or the bucket       | `KEPT_STORAGE_DIR` or the bucket, backed up on its own |
| `KEPT_SECRET_KEY` | Keep it with the backups                | Keep it with the backups                               |

Kept's own backups cover the SQLite database only. Whichever backend you use, restore the files that belong to
the same moment as the database (bills show their PDFs only if the files are there) and use the same
`KEPT_SECRET_KEY`, otherwise stored integration tokens can't be decrypted. Restore the database and the stored files together: files
with no matching database row are deleted by the cleanup after a 24 hour grace period.

**SQLite and local files.** The database holds everything (including institution logos) except uploaded bill
PDFs, which live next to it in `/data/documents`. Back up the whole `/data` volume, and keep `KEPT_SECRET_KEY`
with it.

Files Kept writes to storage are owner-only (mode `0600`, folders `0700`). Host-side tools that read the volume
(backup agents, rsync) must run as the same user id as Kept. Stored files are kept inside the storage folder by
checking the path, not by resolving symlinks, so do not create symlinks inside it.

- **Download:** the administrator can download a consistent copy of the database under
  **Backup** in the sidebar. It is taken with SQLite's `VACUUM INTO`, so it is safe while Kept is
  running.
- **Scheduled:** set `KEPT_BACKUP_DIR` to write `kept-backup-YYYYMMDD-HHMMSS.db` once a day
  (checked hourly, first run a minute after start) and keep the newest `KEPT_BACKUP_KEEP`. The
  directory should be on a different disk or synced elsewhere, otherwise it does not protect
  against losing the volume.

Restoring is done on the server, because the database file must not be replaced while Kept runs:

```bash
docker stop kept
# replace the database; remove the old WAL files so they are not applied to the restored file
docker run --rm -v kept-data:/data -v "$PWD":/backup alpine sh -c \
  'rm -f /data/kept.db-wal /data/kept.db-shm && cp /backup/kept-backup-20260101-030000.db /data/kept.db && chown 1001:1001 /data/kept.db'
docker start kept
```

Restore a backup taken by the same or an older version of Kept; pending migrations run on start. A backup from
a newer version than the running image is not supported. Restore the matching `documents` folder too if bills
must show their PDFs. Check that you can sign in before deleting the volume's previous contents.

**PostgreSQL.** The Backup page has no download (the endpoint answers 404) and `KEPT_BACKUP_DIR` is ignored
with a log line at startup. Back the database up with `pg_dump` or your provider's managed snapshots:

```bash
pg_dump --format=custom --file=kept.dump "$DATABASE_URL"
```

To restore, stop Kept, restore into an empty database (`pg_restore --no-owner --dbname=... kept.dump`, or
the provider's restore) and start Kept again. The same version rules apply as for SQLite: restore a dump of the
same or an older version.

**File storage.** With local files, `KEPT_STORAGE_DIR` is a plain directory to include in your file backups
(`documents/` for bills, `pending-imports/` for unconfirmed uploads, which can be lost). With S3, Kept never
copies the bucket: use versioning, replication or the provider's backup for it.

### Two-factor authentication

Every user can turn on a second step under **Settings > Security**. Once either factor is set up,
a correct password alone no longer signs in: Kept asks for a second factor first, and the
half-finished login expires after 5 minutes and grants access to nothing.

- **Authenticator app (TOTP, RFC 6238):** scan the QR code or type the key, then confirm with a
  current code before it is switched on. The secret is stored encrypted with `KEPT_SECRET_KEY`.
  Each code works once (replays within the same 30 second step are refused). Ten one-time
  recovery codes are shown once, stored hashed, and can be regenerated. Disabling TOTP or
  regenerating recovery codes needs your password and a code.
- **Passkeys (WebAuthn):** register as many as you like, name, rename and delete them. A passkey
  can be used as the second step after the password, or on its own with **Sign in with a
  passkey** (passkeys always require user verification, so that counts as full authentication).
- **Rate limiting:** the second step is rate limited like the password step, and five wrong codes
  discard the pending login so the password has to be entered again.
- **Lost device:** an administrator can reset a user's two-factor under **Users**. This removes
  the authenticator, recovery codes and passkeys, signs the user out everywhere and records a
  `two_factor_reset` entry (who, whom, when; never any secrets) in the `auth_events` table and
  the server log. If the only administrator loses their second factor, delete that user's rows
  in `totp_credentials`, `recovery_codes` and `passkeys` directly in the database.
- **Administrator confirmation:** creating or deleting users, resetting two-factor and downloading
  a backup ask the acting administrator for their password. With an authenticator app, a current
  code (or a recovery code) is required as well. An administrator whose only second factor is a
  passkey must first confirm with a passkey on **Settings > Security** (valid for five minutes)
  and then enter the password. Each action and its audit row are written in one transaction;
  failed confirmations, rate-limit hits (once per window) and backup links are audited too.

**Passkeys and `ORIGIN`:** a passkey is tied to the relying party ID, which Kept takes from the
host name of `ORIGIN` (for example `https://kept.example.org` gives `kept.example.org`), and the
browser must be on exactly that origin. Set `ORIGIN` to the URL you actually use, over HTTPS (or
`localhost` for development). If you change the host name later, existing passkeys stop working
until you re-register them; TOTP is unaffected. Without `ORIGIN`, the request URL is used, which
is only correct when Kept is not behind a proxy.

### External API for companion apps

Apps you run yourself can read bills, transactions, categories, recurring payments and account
names, and attach links to bills and transactions, through a small token-authenticated API under
`/api/external/v1/`. Create a token in **Settings → API tokens**: it carries only the permissions
you tick (optionally limited to some categories), can expire, and can be revoked at any time. Set
`ORIGIN` to the public URL so the `url` fields in responses are correct. See
[docs/external-api.md](docs/external-api.md) for the endpoints, fields and examples.

### Paperless-ngx

Paperless-ngx 2.16 or newer (2.18+ recommended). In Paperless create a dedicated user with view
and change permission on documents (plus view on tags, saved views, custom fields and tasks) and
an API token. Then in Kept open **Settings → Integrations**, enter the address and token, choose
the tag or saved view that holds your bills, and optionally map custom fields. Kept checks for new
documents every 30 minutes; for immediate imports add the Paperless workflow shown on that page
(trigger "Document Added", webhook action with the secret header). If Paperless runs with
`PAPERLESS_WEBHOOKS_ALLOW_INTERNAL_REQUESTS=false`, it can't call a Kept instance on a private
address — the periodic sync still works. Kept connects to whatever address you enter, including
private ones, so only give accounts to people you trust. Requests that must not reach private
networks (Paperless for members, ntfy, webhooks) are made directly and ignore `HTTP_PROXY` and
`HTTPS_PROXY`, because Kept resolves and checks the destination itself.

### Install as an app

Over HTTPS (or on `localhost`), browsers offer to install Kept from the address bar or menu. A
service worker caches only the build's static files (scripts, styles, fonts, icons) so the app
loads faster. Pages, API responses, bill documents, logos and everything else that needs your
session always come from the server and are never stored on the device, so there is nothing to
clear on logout. When the server can't be reached, an offline page is shown instead of the
browser's error page. After an update is deployed, a toast offers to reload into the new version.

Behind a reverse proxy or CDN, don't cache or rewrite `/sw.js` and `/workbox-*.js`: Kept sends
`Cache-Control: no-cache` for them, and a proxy that overrides that delays updates to the app.

## Development

Requires [Bun](https://bun.sh) and [prek](https://github.com/j178/prek).

```bash
bun install
prek install
bun dev
bun run verify   # format, lint, typecheck, tests (SQLite)
bun run test:pg  # the same tests on PostgreSQL
```

`test:pg` needs `KEPT_TEST_DATABASE_URL`, the URL of a throwaway PostgreSQL server (each test file gets its
own database; without the variable the PostgreSQL tests are skipped). The S3 contract tests run when
`KEPT_TEST_S3_ENDPOINT`, `KEPT_TEST_S3_BUCKET`, `KEPT_TEST_S3_ACCESS_KEY_ID` and `KEPT_TEST_S3_SECRET_ACCESS_KEY`
point at a throwaway bucket. `bun run db:generate` writes the migrations for both databases.

Test fixtures are synthetic; never commit real statements or bills.

## License

[PolyForm Noncommercial 1.0.0](LICENSE) — free for personal and other noncommercial use.