Ledgard is a single-ledger family finance web application. It is designed as a self-hosted PWA on Cloudflare with one isolated app/database per family.
- Frontend: React 19, Vite, TypeScript, Tailwind CSS, TanStack Query, React Router v7, PWA.
- Backend: Hono on Cloudflare Workers.
- Database: Cloudflare D1 (SQLite).
- Hosting: Cloudflare Workers Assets.
- Auth: Cloudflare Access in production,
DEV_USER_EMAILfallback only in local development. - Local orchestration: Govard.
- Liquidity-first dashboard: available cash first, net worth second.
- Accounts: cash, bank, credit card, savings.
- Transaction ledger: income, expense, transfer, loan, debt, debt collection, repayment.
- Category hierarchy: strict parent -> child.
- Shared and individual monthly budgets.
- Recurring budgets: auto-create budgets on monthly, quarterly, or yearly schedules via Worker cron.
- Budget templates: save current budgets as reusable templates (personal or shared).
- Period locking through D1 triggers.
- Scheduled recurring transactions via Worker cron.
- English and Vietnamese UI (switchable in Settings).
- Currency setting for common currencies:
USD,VND,EUR,GBP,JPY,SGD,AUD,CAD.
migrations/ D1 schema, triggers, seed data
src/client/ React app
src/worker/ Hono Worker API and cron handler
src/shared/ Shared types and finance utilities
tests/ Unit tests
docs/ Product specification
wrangler.toml.example Cloudflare Worker/D1/Assets config template
.dev.vars.example Local dev environment vars template
.govard.yml Govard local environment config
.govard/ Govard project overrides
Requirements:
- Govard installed and configured
- Docker running
Govard runs two services:
web: Vite frontend on port80worker: Wrangler Worker API on port8787
The Vite dev server proxies /api to http://worker:8787 through LEDGARD_API_PROXY_TARGET.
- Start shared Govard services:
govard svc up- Install dependencies:
govard tool npm install- Start the project:
govard up- Apply local D1 migrations:
govard env exec worker npm run db:migrate:local- Open the app:
https://ledgard.test/
Health check:
https://ledgard.test/api/health
govard ps # Check service status
govard logs -f # Follow logs
govard restart # Restart services
govard down # Stop services
govard diag --fix # Fix common issues
govard env cleanup # Clean up Docker resourcesIf the browser reports an SSL or .test DNS problem:
govard svc up
govard doctor trustIf a port or Docker resource is stuck:
govard diag --fix
govard env cleanupThere is no app-owned password login.
Local development (Govard):
- Worker uses
DEV_USER_EMAILfrom.dev.vars - Maps the request to a local member (default:
admin@ledgard.local) - This fallback is disabled when
ENVIRONMENT=production
Production:
- Cloudflare Access protects the app hostname
- Cloudflare Access injects
CF-Access-Authenticated-User-Email - Worker maps that email to
members.email - If the email is not in
members, API returns403
Local migrations (inside Govard):
govard env exec worker npm run db:migrate:localRemote migrations (production):
npm run db:migrate:remoteSchema files:
- migrations/0001_initial_schema.sql
- migrations/0002_triggers_indexes.sql
- migrations/0003_seed_defaults.sql
See wrangler.toml.example for the production configuration template.
Period lock:
system_settings.global_lock_until_datecontrols the lock date.- D1 triggers block
INSERT,UPDATE, andDELETEfor transactions dated on or before that date.
This app is designed to run as a single Cloudflare Worker with bundled static assets and one D1 database.
Production requirements:
- Cloudflare account.
- A Cloudflare-managed domain or subdomain, for example
ledger.example.com. - Cloudflare Zero Trust enabled for Access authentication.
- Wrangler login on the machine that deploys.
- A production admin email that will be allowed by Cloudflare Access and seeded into
members.
Authenticate Wrangler:
npx wrangler loginConfirm the active Cloudflare account:
npx wrangler whoamiCreate the D1 database:
npx wrangler d1 create ledgard-dbCopy the generated database_id into your wrangler.toml. See wrangler.toml.example for the full configuration template:
[[d1_databases]]
binding = "DB"
database_name = "ledgard-db"
database_id = "YOUR_REAL_DATABASE_ID"wrangler.toml should NOT be committed. Use wrangler.toml.example as reference.
Production vars in your wrangler.toml should match this template from wrangler.toml.example:
[vars]
ENVIRONMENT = "production"
DEFAULT_LOCALE = "en"
DEFAULT_CURRENCY = "USD"Do not set DEV_USER_EMAIL in production.
If production needs a different default currency, set DEFAULT_CURRENCY before first deploy or update it later in Settings.
Use npm ci for clean production installs:
npm ci
npm run typecheck
npm run test
npm run buildApply schema, triggers, indexes, and seed defaults to the remote D1 database:
npm run db:migrate:remoteVerify the remote tables exist:
npx wrangler d1 execute ledgard-db --remote --command "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;"Seed the first production admin member. Replace you@example.com and Family Admin:
npx wrangler d1 execute ledgard-db --remote --command "INSERT INTO members (id, name, email, role, is_active) VALUES ('mem-admin-prod', 'Family Admin', 'you@example.com', 'admin', 1) ON CONFLICT(email) DO UPDATE SET name = excluded.name, role = 'admin', is_active = 1;"For a real family bootstrap, you may keep a private local seed file such as seeds/production_vi.sql and run it from the deploying machine:
npx wrangler d1 execute ledgard-db --remote --file seeds/production_vi.sqlDo not push production seed files to git when they contain real family emails, account names, or private defaults. .gitignore excludes seeds/production*.sql.
Confirm the admin exists:
npx wrangler d1 execute ledgard-db --remote --command "SELECT id, name, email, role, is_active FROM members;"Deploy:
npm run deployWrangler will upload:
- Worker API from
src/worker/index.ts. - Static assets from
dist/client. - Cron trigger from your
wrangler.toml. - D1 binding named
DB.
In Cloudflare Dashboard, attach the Worker to the production hostname, for example:
ledger.example.com
Recommended path:
- Cloudflare Dashboard -> Workers & Pages ->
ledgard. - Settings -> Domains & Routes.
- Add a custom domain such as
ledger.example.com. - Wait until the domain is active.
Configure Cloudflare Access after the Worker custom domain exists:
- Cloudflare Dashboard -> Zero Trust -> Access -> Applications.
- Add a Self-hosted application.
- Use the same hostname as the Worker custom domain, for example
ledger.example.com. - Add an allow policy for family member emails.
- Use OTP/magic-link authentication.
- Make sure each allowed email also exists in the
memberstable.
Important: Cloudflare Access must sit in front of the same hostname users open in the browser. The app itself has no password login.
Open the health endpoint:
https://ledger.example.com/api/health
Expected response:
{"data":{"ok":true,"service":"ledgard","timestamp":"..."}}Open the app:
https://ledger.example.com/
Expected behavior:
- Browser is redirected to Cloudflare Access login if not authenticated.
- After OTP login, the dashboard loads.
- If the authenticated email is not present in
members, API returns403.
After the first admin can access the app, add members through Settings or seed them through D1:
npx wrangler d1 execute ledgard-db --remote --command "INSERT INTO members (id, name, email, role, is_active) VALUES ('mem-member-1', 'Family Member', 'member@example.com', 'member', 1) ON CONFLICT(email) DO UPDATE SET name = excluded.name, role = excluded.role, is_active = 1;"Also add the same email to the Cloudflare Access allow policy.
For normal updates:
git pull
npm ci
npm run typecheck
npm run test
npm run build
npm run db:migrate:remote
npm run deployBefore destructive schema changes, export or backup D1 first:
npx wrangler d1 export ledgard-db --remote --output ledgard-backup.sqlCloudflare Workers keeps deployment versions in the dashboard. If a deploy breaks the app:
- Cloudflare Dashboard -> Workers & Pages ->
ledgard. - Deployments.
- Roll back to the last known-good deployment.
- Do not roll back D1 schema without a matching backup plan.
Common issues:
Permission errors during deployment:
If npm ci fails with EACCES due to Govard-generated files owned by root:
govard env exec web sh -lc 'chown -R 1000:1000 /app/node_modules /app/.wrangler 2>/dev/null || true'
npm ciD1 migration issues:
- D1 remote migrations can fail with
incomplete inputwhen trigger bodies use multi-lineSELECT CASE ... THEN RAISE(...) END. Use the shorter form:
SELECT RAISE(ABORT, 'MESSAGE')
WHERE condition;- D1 remote SQL file execution rejects explicit
BEGIN TRANSACTION,COMMIT, andSAVEPOINTstatements. Do not wrap seed files in manual transactions.
Authentication issues:
- The production Worker URL may return
401 Missing Cloudflare Access identityuntil Cloudflare Access is configured./api/healthremains public for health checks. - Local Govard dev uses
.dev.varswithENVIRONMENT=developmentandDEV_USER_EMAIL. See.dev.vars.examplefor the template.
Network issues:
http://ledgard.test/redirects to HTTPS. If terminal DNS lookup for.testis slow due to IPv6/AAAA resolution, use IPv4:
curl -4 -k https://ledgard.test/api/healthCron is configured in wrangler.toml.example:
[triggers]
crons = ["0 0 * * *"]The scheduled Worker scans active scheduled transactions and inserts due ledger entries.
Miniflare local dev does not automatically trigger scheduled Workers. Test cron behavior through the service function or Wrangler scheduled testing mode when needed.
Ledgard supports recurring budgets that automatically create budgets on a schedule:
- Monthly: Creates budget every month
- Quarterly: Creates budget every 3 months (Jan, Apr, Jul, Oct)
- Yearly: Creates budget once per year
Recurring budgets are processed by the Worker cron handler (runs daily at midnight UTC). The system uses idempotency tracking via the recurring_budget_runs table to prevent duplicate budget creation.
GET /api/recurring-budgets- List all recurring budgetsPOST /api/recurring-budgets- Create new recurring budget (admin only)PATCH /api/recurring-budgets/:id- Update recurring budget (admin only)DELETE /api/recurring-budgets/:id- Delete recurring budget (admin only)
Save current month's budgets as a template for reuse:
- Personal templates - Visible only to creator
- Shared templates - Visible to all members (admin only)
- Apply template to any future month
- Prevents duplicate budgets when applying template multiple times
GET /api/budget-templates- List templates (user's + shared)GET /api/budget-templates/:id/items- Get template itemsPOST /api/budget-templates- Create template (admin only)PATCH /api/budget-templates/:id- Update template (admin only)DELETE /api/budget-templates/:id- Delete template (admin only)POST /api/budget-templates/:id/apply- Apply template to target month (admin only)
After deploying code updates that include Gravatar support, existing members need their avatars migrated. The migration endpoint is admin-only and protected by Cloudflare Access.
To run the migration:
- Open your production app in a browser (e.g.,
https://ledger.example.com) - Log in through Cloudflare Access
- Open Developer Tools (F12) → Console tab
- Run this code:
fetch('/api/members/migrate-avatars', {
method: 'POST',
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => console.log('Migration result:', data))
.catch(err => console.error('Error:', err))Expected response:
{"data":{"updated":2}}This will generate Gravatar URLs for all members based on their email addresses. New members automatically get Gravatar URLs when created.
Development (via Govard):
govard up # Start web and worker services
govard tool npm run typecheck # TypeScript check
govard tool npm run test # Vitest unit tests
govard tool npm run test:watch # Vitest in watch mode
govard tool npm run build # Typecheck and build frontend assets
govard env exec worker npm run db:migrate:local # Apply D1 migrations locallyProduction deployment:
npm run db:migrate:remote # Apply D1 migrations remotely
npm run deploy # Build and deploy Worker/AssetsRun before committing or deploying:
govard tool npm run typecheck
govard tool npm run test
govard tool npm run buildSmoke checks:
curl -4 -k https://ledgard.test/api/health
govard ps
govard logs -f- All local development uses Govard for consistent environment setup
wrangler.toml.examplecontains a template;wrangler.tomlwith real D1 id should NOT be committed- Seed data is for development and MVP smoke testing
- Production seed files such as
seeds/production_vi.sqlare local/private bootstrap files and should not be pushed to git - Currency switching changes the app/base display setting. It does not perform historical FX conversion
- The MVP debt/loan model uses ledger transaction types only; it does not yet have separate counterparty or obligation tables
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
See Local Setup section above for prerequisites and setup instructions.
Before submitting a PR, ensure:
govard tool npm run typecheck # TypeScript passes
govard tool npm run test # All tests pass
govard tool npm run build # Build succeedsThis project is licensed under the MIT License - see the LICENSE file for details.