Unified Ledger is a mobile-first personal finance app built with Next.js (App Router), Drizzle ORM, SQLite (default) and Better Auth.
pnpm install
pnpm devApp: http://localhost:3000
Unified Ledger is designed to run as a single container on Unraid CA. The container:
- Runs migrations automatically on startup (non-interactive)
- Persists data under
/config(Unraid appdata mount) - Exposes the web UI on port 3000
- 3000/tcp: Web UI
Mount /config as a persistent volume.
- SQLite DB (default):
/config/finance.db - Uploads:
/config/uploads(or setUPLOADS_DIR)
NEXT_PUBLIC_APP_URL: public base URL used for auth redirects and email links
Example:http://tower:3000orhttps://unifiedledger.example.comBETTER_AUTH_SECRET: long random secret (required for production)DATABASE_URL: SQLite, defaultfile:/config/finance.dbFORCE_SECURE_COOKIES: defaults tofalse
IfNEXT_PUBLIC_APP_URLstarts withhttps://, cookies are marked Secure automatically; setFORCE_SECURE_COOKIES=trueonly if you know you need it.
UPLOADS_DIR: defaults to/config/uploadsin production- Avatars are stored under
${UPLOADS_DIR}/avatars/<userId>.jpg - Avatars are served via an authenticated route:
/uploads/avatars/<userId>.jpg
- Avatars are stored under
- First run: the container auto-applies DB migrations and creates tables.
- Upgrades: pulling a new image and restarting the container runs migrations again; if there are no pending migrations, startup continues normally.
- If migrations fail: the container exits non-zero and Unraid will show it as unhealthy; check logs for the reason.
GET /api/healthperforms a real DB query and returns503if the DB is unreachable/misconfigured.
When running behind a reverse proxy:
- Set
NEXT_PUBLIC_APP_URLto the externally reachable URL (typically HTTPS). - Ensure the proxy forwards
HostandX-Forwarded-Protocorrectly.
Login/session loops are almost always caused by:
NEXT_PUBLIC_APP_URLnot matching the public URL, or- missing/incorrect forwarded headers.
Two image channels are published to GHCR:
:nightly(pre-release channel) — rebuilt on every push tomain(.github/workflows/nightly.yml). Ungated (no test run) and intended for the maintainer's own box to try changes before cutting a release. Each build is also taggedsha-<commit>for rollback.:latest/X.Y.Z(stable channel) — published only when a version tag is cut. This is what user deployments should track.
CI (type check, migration verification, full test suite, money-integrity check)
runs when a release is cut and on pull requests — not on pushes to main.
To cut a release from main:
pnpm release:patch # or release:minor / release:major / release:rcThis bumps package.json, commits, tags vX.Y.Z, and pushes main with the
tag. The tag triggers .github/workflows/publish-ghcr.yml, which:
- Runs the full test workflow — a failing suite blocks the release
- Builds and pushes the image to GHCR tagged
X.Y.Z,X.Y, andlatest - Scans the published image with Trivy (results in the repo's Security tab)
- Creates a GitHub Release with auto-generated notes
release:rc cuts a pre-release (vX.Y.Z-rc.N): same gated pipeline, but the
image is tagged only with its exact version (:latest and X.Y don't move)
and the GitHub Release is marked as a pre-release — useful for sharing a
release candidate without pushing it to everyone on :latest.
- Container exits immediately complaining about
BETTER_AUTH_SECRET- Set
BETTER_AUTH_SECRETin your Unraid template (use a long random secret).
- Set
- SQLite lock error:
/config/.migrate.lockalready exists- Ensure only one container instance is running.
- If a previous run crashed, delete
/config/.migrate.lockand restart.
- Scheduled jobs (autopay, backups) aren't running
- The container starts its own scheduler that triggers
/api/cron/*endpoints. Check the logs for[cron-scheduler] started. It authenticates withCRON_SECRET(auto-generated to/config/.cron-secretwhen unset). - Using external cron instead? Set
CRON_SCHEDULER_DISABLED=trueand call the endpoints yourself withAuthorization: Bearer $CRON_SECRET.
- The container starts its own scheduler that triggers
Three layers, from most to least automatic:
- Pre-migration snapshots — before applying pending migrations at startup,
the container writes a consistent copy of the DB to
/config/backups/pre-migration/(last 3 kept). Rolling back a bad deploy: stop the container, copy the snapshot over/config/finance.db, start the previous image tag. - Daily raw DB snapshots — the backup cron writes a
VACUUM INTOcopy of the whole database to/config/backups/db-snapshots/(last 7 kept). Restore: stop the container, copy a snapshot over/config/finance.db, start. - Per-household JSON exports — the in-app scheduled backups
(
/config/backups/<user>/<household>/), restorable through the app's import.
All of these live on the same disk as the database. For real durability,
sync /config/backups/ off-box — on Unraid, point a User Scripts rclone/rsync
job (or the CA Appdata Backup plugin) at another share or cloud storage.
Do a restore drill once: copy a db-snapshots file over a scratch
finance.db, boot a container against it, and confirm your data is there. An
untested backup is a hope, not a backup.
The container ships a Docker HEALTHCHECK against /api/health, so
docker ps shows healthy/unhealthy. To get NOTIFIED on Unraid instead of
noticing later:
-
Install the CA Docker Autostart/Monitor-style notification plugin, or add a User Scripts cron (e.g. every 5 minutes):
#!/bin/bash state=$(docker inspect -f '{{.State.Health.Status}}' unifiedledger 2>/dev/null) if [ "$state" != "healthy" ]; then /usr/local/emhttp/webGui/scripts/notify \ -s "UnifiedLedger unhealthy" -d "Container health: ${state:-missing}" -i alert fi
-
Also watch the startup logs after each update: the container prints
[verify-money-integrity]results and[cron-scheduler] startedon boot.