A web-based double-entry accounting application for personal finance management, built with modern technologies and accounting best practices.
6
stars
8
commits
TypeScript
primary language
Aug 28, 2026
updated
A web-based double-entry accounting application for personal finance management, built with modern technologies and accounting best practices. Supports multiple books per user, investment tracking, and bank sync via Plaid.
Every screenshot below is the sample data you get from Add demo book — no setup, and nothing real in it.
REGISTRATION_ENABLEDcpk_ keys managed on the Account page, scrypt-hashed at rest? help overlaygit clone https://github.com/jeffjjohnston/counterpoise-ledger.git
cd counterpoise-ledger
npm install
docker volume create counterpoise_pgdata
docker compose up -d postgres
Local development only needs the database. For a full Docker deployment, copy
the example environment file and point DATABASE_URL at the postgres service
— inside the app container, localhost is the container itself:
cp .env.example .env.production.local
# Then edit .env.production.local. Set an application-role password, and put
# that same password into DATABASE_URL — nothing derives one from the other:
# APP_DB_PASSWORD=$(openssl rand -hex 32)
# DATABASE_URL=postgresql://counterpoise_app:<that password>@postgres:5432/counterpoise
docker compose --env-file .env.production.local up -d --build
--env-file is required, not optional: Compose reads ${VAR} substitutions in
docker-compose.yml from the shell, a .env file, or --env-file — a
service-level env_file: populates the container but does not feed those
substitutions. Without it, TZ and POSTGRES_PASSWORD silently keep their
defaults.
npm run db:create-test-dbs
Docker only creates the counterpoise database; local development uses counterpoise_dev, which this script creates (along with the databases used by the test suite).
npm run db:seed
This resets the local database, creates a sample admin user with password password, creates a sample book, and seeds it with data. If you want to seed an existing book instead, first create the book, then run npm run db:list-books to find its ID and npm run db:seed -- --book-id <id>.
If you skip seeding, run npm run db:migrate instead to apply the schema — migrations are not applied automatically in local dev.
npm run dev
If you ran npm run db:seed, sign in with admin / password. Otherwise, register an account and create a book.
To explore with realistic data instead of an empty book, click Add demo book
on the books page. It creates a book named "Demo Book" and fills it with the
same sample dataset the seed uses. Unlike npm run db:seed, which resets the
entire database, this only ever writes to the book it just created — so it is
safe to run on an instance that already holds real data, and you can add several.
It writes thousands of rows one at a time, so give it a few seconds.
Counterpoise uses a PostgreSQL database containing all meta tables (users, sessions, books) and book-scoped tables. Book-scoped tables have a bookId foreign key for data isolation. Local development defaults to postgresql://counterpoise:counterpoise@localhost:5432/counterpoise_dev when DATABASE_URL is unset; Docker deployment uses counterpoise via .env.production.local.
The full stack runs as three Docker Compose services:
| Service | Description |
|---|---|
postgres | PostgreSQL 16 database with persistent volume |
app | Next.js standalone server (runs migrations on startup) |
scheduler | PostgreSQL Alpine sidecar — recurring transactions, Plaid sync, backups, pruning, reindex |
The app and scheduler services read secrets from .env.production.local via env_file. Configure these variables:
# .env.production.local
CRON_SECRET=your-cron-secret-here
# Optional — signup control. Leave unset and registration is open only until the
# first account exists, then closes itself.
REGISTRATION_ENABLED=true|false
# Optional — Plaid bank sync (see "Connecting a Bank (Plaid)" below)
PLAID_CLIENT_ID=...
PLAID_SECRET=...
PLAID_ENV=sandbox|production
# Optional — Tiingo security prices
TIINGO_API_KEY=...
# Optional — PostHog analytics
# NEXT_PUBLIC_* values are Docker build args (inlined into the JS bundle at image build)
NEXT_PUBLIC_POSTHOG_KEY=...
NEXT_PUBLIC_POSTHOG_HOST=...
POSTHOG_PERSONAL_API_KEY=... # runtime; used for querying the PostHog API
Set DATABASE_URL in .env.production.local, pointing at the internal
postgres hostname and at the application role — for example
postgresql://counterpoise_app:<app password>@postgres:5432/counterpoise. That
role is created from APP_DB_PASSWORD by
scripts/postgres-init/01-app-role.sh, which runs on first initialization
only: the postgres image skips /docker-entrypoint-initdb.d once the volume
holds a database. Set APP_DB_PASSWORD before the first docker compose up —
setting it later does nothing. The app container refuses to start while
DATABASE_URL still carries the published counterpoise:counterpoise default,
which is in this repository and known to every reader of it.
# Create the persistent data volume (first time only)
docker volume create counterpoise_pgdata
# Build and start all services
docker compose --env-file .env.production.local up -d --build
# Or start just the database (for local dev)
docker compose up -d postgres
The app will be available at http://localhost:3000. Migrations run automatically on container startup via docker-entrypoint.sh.
Rebuild the app image after code changes:
docker compose --env-file .env.production.local up -d --build app
Docker Compose reads env_file only when creating a container. After editing .env.production.local, force-recreate to pick up changes:
docker compose --env-file .env.production.local up -d --force-recreate app scheduler
Note:
docker compose restartwill not re-read the env file — it only stops and starts the existing container with the old environment.
# All services
docker compose logs -f
# Specific service
docker compose logs -f app
# Stop all services (data persists in the pgdata volume)
docker compose down
# Stop and delete the database volume (external volume must be removed separately)
docker compose down
docker volume rm counterpoise_pgdata
The Dockerfile uses a multi-stage build:
node_modules via npm ciThe entrypoint runs Drizzle migrations before starting the Next.js server, so schema changes are applied automatically on deploy.
In a Docker deployment this is not just hardening advice — it is what makes login work at all.
The image runs with NODE_ENV=production, which marks the session cookie
Secure. Browsers refuse to store a Secure cookie that arrives over plain
http://, with one exception: localhost, which they treat as a trustworthy
origin. So the same build behaves differently depending on how you reach it:
| Reached at | Login |
|---|---|
http://localhost:3000 | Works — browsers exempt localhost |
http://192.168.1.50:3000 | Fails silently. Correct password, 200 response, and straight back to the login page |
https://books.example.com | Works |
The middle row has no error message, so it looks like a rejected password. It is
what you get by setting APP_BIND=0.0.0.0 and pointing a phone at the LAN
address. Counterpoise logs a warning when it happens — check docker compose logs app if login is bouncing.
Any of these fixes it:
| Option | What it needs | Notes |
|---|---|---|
| Localhost only | Nothing | No TLS needed. Fine if you use Counterpoise on the machine it runs on. |
| Tailscale Serve | A tailnet | tailscale serve --bg 3000 publishes it at https://<machine>.<tailnet>.ts.net. No open ports, no domain, no certificate management, and it preserves Host. Easiest option for reaching your own instance from other devices. |
| Caddy | A domain, ports 80/443 | Automatic Let's Encrypt certificates from a two-line Caddyfile. Sets Host and X-Forwarded-Proto correctly by default. |
| Cloudflare Tunnel | A domain on Cloudflare | cloudflared dials out, so nothing needs to be opened inbound. |
| nginx + certbot | A domain, ports 80/443 | Works, but needs both proxy headers set by hand — see below. |
Leave APP_BIND at its 127.0.0.1 default when the proxy runs on the same
host; the proxy reaches the app over loopback and nothing else can.
A Caddyfile is the whole configuration:
books.example.com {
reverse_proxy 127.0.0.1:3000
}
nginx needs two headers set explicitly. Its defaults break Counterpoise in two
separate ways — Host becomes the upstream address, which makes every write
fail the cross-origin check, and without X-Forwarded-Proto the app cannot tell
that the original request was HTTPS:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
Once TLS is working, set ENABLE_HSTS=true to add a Strict-Transport-Security
header.
Counterpoise was built for a single trusted household on a home LAN. Before exposing it to anything wider, understand these defaults:
Secure under NODE_ENV=production, so login fails
silently over plain HTTP anywhere but localhost. See "Getting HTTPS" above
for the ways to do it. A baseline set of security headers (CSP,
X-Frame-Options, Referrer-Policy, Permissions-Policy) is always sent;
HSTS is added only when you set ENABLE_HSTS=true.REGISTRATION_ENABLED has
three states: unset means open only while no account exists, true means
always open, false means always closed. The unset default is what lets a
fresh install bootstrap its first account and then shut by itself, with no
configuration step and no window where a forgotten default leaves signup open.
To add someone later, set it to true, register them, and unset it again.127.0.0.1 by default, so a reverse proxy is the only
way in. APP_BIND=0.0.0.0 publishes it on your LAN instead — which bypasses
whatever authentication that proxy provides, and, if you reach it over plain
HTTP, silently breaks login. See "Getting HTTPS" above.npm run db:seed creates an admin / password account. Delete or change
it before the instance is reachable by anyone else.127.0.0.1 by default and its bootstrap superuser uses
the password from POSTGRES_PASSWORD. Change it before altering that binding.counterpoise_app from APP_DB_PASSWORD, and the app container
refuses to start if DATABASE_URL still carries the published default./api/cron/* returns 401 unless CRON_SECRET
is set and presented as a bearer token.Host
header. The cross-origin write check in proxy.ts compares the request's
Origin against its Host header. Tailscale Serve preserves Host by
default, so this works out of the box behind it. nginx does not — its
default proxy_set_header Host $proxy_host replaces Host with the
upstream address — and a proxy on that default will 403 every write with an
opaque "Cross-origin request rejected". Configure proxy_set_header Host $http_host; (or equivalent) if you front Counterpoise with nginx or a
similar proxy.The scheduler container dumps the database hourly to ./backups, prunes dumps
older than 30 days, and reindexes monthly.
# Take a backup now
docker exec counterpoise-scheduler-1 sh -c \
'pg_dump -Fc "$DATABASE_URL" > /backups/manual-$(date +%Y%m%d-%H%M%S).dump'
# List a dump's contents without restoring
pg_restore --list backups/<file>.dump
# Full restore (drops and recreates all objects) — stop the app first
docker compose stop app
pg_restore --clean --if-exists -d "$DATABASE_URL" backups/<file>.dump
docker compose start app
Everything above runs on one disk. Hourly dumps beside the database they came from protect you from a bad migration or a mistaken delete — not from disk failure, theft, or ransomware, all of which take the database and every dump together. Nothing in this repo can fix that for you; it needs a second place.
Two ways, either is fine:
A whole-disk backup service already covering the host — Backblaze, Time
Machine to a separate drive, or equivalent. Nothing to configure here, as
long as ./backups is not in an exclusion list. Check that it is actually
being picked up rather than assuming it.
A scheduled copy of the newest dump to cloud storage or another machine.
Run this from the host's crontab, not the scheduler container: that
container is postgres:16-alpine and has no rclone. Use an absolute path
to your checkout — /backups is the path inside the container, and cron
has no working directory to speak of:
0 5 * * * rclone copy "$(ls -t /srv/counterpoise/backups/counterpoise-*.dump | head -1)" remote:counterpoise/
restic, rsync over SSH, or aws s3 cp all work the same way. Putting the
copy in the scheduler's crontab instead means building your own image with
the tool installed — the stock one cannot do it.
Whichever you pick, keep version history. A backup that mirrors the current state one-for-one will faithfully replicate a corruption or an encryption event to your only other copy. Thirty days of retention turns that from a disaster into an inconvenience.
You do not need to verify the dumps yourself — the scheduler already does, on every one (see Monitoring below). What it cannot do is put them somewhere else.
Counterpoise verifies each dump with pg_restore --list and records the outcome
of every scheduled job to backups/status/. The app surfaces stale or
unverified jobs in the navbar — silently, until something needs attention.
That design assumes you use the app. It detects a broken backup job while everything else works, but it cannot tell you the host is switched off, because it is running on that host. On a machine you open regularly that gap is covered by you noticing.
If you deploy this somewhere you don't look at daily, add an external dead-man
switch — healthchecks.io or similar — by appending a ping to each cron line in
docker-compose.yml. That is the only layer that still reports when the whole
host is down.
The scheduler container runs all cron jobs on a postgres:16-alpine image (giving it access to pg_dump, reindexdb, and wget):
| Job | Schedule | Description |
|---|---|---|
| Recurring transactions | Hourly | Calls /api/cron/recurring authenticated with CRON_SECRET |
| Plaid sync | Every 6 hours | Calls /api/cron/plaid-sync for all linked asset/liability accounts |
| Security price sync | Tue–Sat 6am ET | Calls /api/cron/price-sync to fetch Tiingo end-of-day prices |
| Database backup | Hourly, 6am–9pm | pg_dump to backups/counterpoise-<timestamp>.dump |
| Backup pruning | Daily at 4am | Deletes .dump files older than 30 days |
| REINDEX | 1st of month at 3am | reindexdb "$DATABASE_URL" |
Manual trigger:
curl -H "authorization: Bearer ${CRON_SECRET}" http://localhost:3000/api/cron/recurring
Bank sync is optional, and off until PLAID_CLIENT_ID and PLAID_SECRET are
set — isPlaidConfigured() is false without them, so sync fails closed rather
than reaching a live institution.
Sign up at dashboard.plaid.com. Your client_id
and per-environment secrets are under Developers → Keys.
Plaid has two environments: Sandbox, which serves fake institutions and
fake transactions, and Production, which connects real banks. There is no
longer a Development environment — Plaid retired it, so PLAID_ENV takes only
sandbox or production.
Production is not gated behind a sales call for a personal deployment. Developers signing up in the US or Canada get the Trial plan: free, real production data, auto-approved for most applicants, capped at 10 connected Items. That is usually enough for one household's banks. (The older Limited Production tier closed to new signups on 15 April 2026.)
Use the Sandbox secret in .env.local and the Production secret in
.env.production.local. .env.example explains why that separation is not
optional: a production secret in .env.local means npm run dev reaches real
banks and bills real API requests, and the separate counterpoise_dev
database does nothing to prevent it — it bounds writes, not outbound calls.
Counterpoise syncs against a stored access token per institution, but it does
not run Plaid Link itself. scripts/plaid-link.ts produces the token:
npm run plaid:link # sandbox, via .env.local
# Or, to connect a real bank with the deployment's credentials:
npx tsx --env-file=.env.production.local scripts/plaid-link.ts
It prints a Plaid-hosted URL. Open it in any browser, log in to the bank, and
the script prints an Item ID and an Access Token when the session
completes. In Sandbox, log in to any institution with user_good /
pass_good.
It stops waiting after ten minutes and prints the command to resume. Use that rather than re-running plain: a fresh link token stops watching the session you opened, so an Item you had already created at the bank would sit on your Plaid plan with no token to exchange for it.
The script uses Hosted Link, where
Plaid serves the Link UI on its own domain, so there is nothing to run locally
and no redirect URI to register. That matters for OAuth institutions — Chase,
Wells Fargo, US Bank — which require a redirect URI that is HTTPS and
registered in the Plaid dashboard, and so cannot be completed against a
http://localhost page at all.
Go to Sync → Manage Sync Tokens, then Add Token. Enter the institution name, and paste the Item ID and Access Token. Counterpoise fetches the institution's accounts, and Assign Accounts maps each one to a Counterpoise account.
From then on the scheduler sidecar syncs every six hours, staging
transactions for reconciliation rather than writing them to the ledger
directly. Review them on the Sync page.
An access token does not expire. Treat it as a credential: it reads the connected account's transactions until revoked from the Plaid dashboard.
Import data from Moneydance JSON exports:
# Dry run (recommended first)
npx tsx scripts/import-moneydance/index.ts path/to/export.json --book-id <existing-book-id> --dry-run
# Full import
npx tsx scripts/import-moneydance/index.ts path/to/export.json --book-id <existing-book-id> --verbose
Create the destination book first in the UI, or use npm run db:seed for a sample seeded book. Use npm run db:list-books to find the book ID before importing.
Imports accounts, payees, transactions, investment transactions, security prices, stock splits, and recurring reminders. See scripts/import-moneydance/README.md for details.
Simple Mode:
Result:
Journal Entry Mode:
The system will automatically show when it's due and allow one-click processing.
| Account Type | Normal Balance | Increase | Decrease |
|---|---|---|---|
| Asset | Debit (+) | Debit | Credit |
| Liability | Credit (-) | Credit | Debit |
| Equity | Credit (-) | Credit | Debit |
| Income | Credit (-) | Credit | Debit |
| Expense | Debit (+) | Debit | Credit |
Buying groceries with credit card:
Paying off credit card:
Receiving salary:
npm run dev # Start development server
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint
npm test # Run unit tests (Vitest)
npm run test:ui # Open Vitest UI
npm run test:coverage # Generate coverage report
npm run test:e2e # Run Playwright E2E tests
npm run db:generate # Generate a migration from /db/schema.ts into /db/migrations
npm run db:migrate # Apply pending migrations
npm run db:create-test-dbs # Create dev + per-worker test databases (one-time setup)
npm run db:list-books # List books and their IDs
npm run db:seed -- --book-id 2 # Full reset + seed sample data for a specific book
npm run mcp:dev # Start the MCP server (stdio)
npm run plaid:link # Mint a Plaid access token for one bank (sandbox)
npx drizzle-kit studio # Open Drizzle Studio (database GUI)
For book schema changes, use this workflow:
/db/schema.tsnpm run db:generatenpm run db:migrate/db/migrations/meta/Migrations are NOT auto-applied by getDb(). Use runMigrations() explicitly in scripts; seed and test helpers handle migrations automatically.
/app
/page.tsx # Home / book list
/login, /register, /account # Auth pages
/b/[bookId]/ # Book-scoped pages
/page.tsx # Dashboard
/accounts, /transactions # Core accounting
/securities, /recurring # Investment & recurring
/payees, /sync # Payees & bank sync
/reports, /search # Financial reports & search
/api/
/auth/ # Authentication
/books/ # Book management
/b/[bookId]/ # Book-scoped API routes
/cron/ # Cron endpoints (recurring, plaid-sync, price-sync)
/components
/ui # Reusable UI components
/accounts, /transactions # Feature components
/securities, /sync, /layout # Domain components
/reports # Financial report components
/db
/schema.ts # Unified database schema (meta + book-scoped tables)
/index.ts # Database connection (getDb)
/seed.ts # Sample data
/lib
/accounting.ts # Accounting helpers
/investments.ts # Investment calculations
/formatters.ts # Display formatters
/api-auth.ts # API authentication
/reports.ts # Financial report logic
/hooks
/useBookId.ts # Client hooks (also useIsMobile, useRegisterShortcuts)
/mcp
/server.ts # MCP server (AI access to accounting data)
MIT — see LICENSE.
Counterpoise is developed as a personal project and is not accepting pull requests, feature requests, or bug reports. That is not unfriendliness — it is the point of publishing it.
Fork it and make it yours. The repository is built for exactly that: CLAUDE.md
is a complete machine-readable contract for the codebase, so your own AI agents
can pick it up and build on it without a human explaining the architecture
first. The .claude/skills/ directory ships the maintainer's own workflows as
worked examples.
If you want to track upstream changes, add this repository as a second remote
and cherry-pick what you want. Releases are tagged vX.Y.Z.
8 commits
TypeScript
99.1%
A web-based double-entry accounting application for personal finance management, built with modern technologies and accounting best practices.
6
stars
8
commits
TypeScript
primary language
Aug 28, 2026
updated
A web-based double-entry accounting application for personal finance management, built with modern technologies and accounting best practices. Supports multiple books per user, investment tracking, and bank sync via Plaid.
Every screenshot below is the sample data you get from Add demo book — no setup, and nothing real in it.
REGISTRATION_ENABLEDcpk_ keys managed on the Account page, scrypt-hashed at rest? help overlaygit clone https://github.com/jeffjjohnston/counterpoise-ledger.git
cd counterpoise-ledger
npm install
docker volume create counterpoise_pgdata
docker compose up -d postgres
Local development only needs the database. For a full Docker deployment, copy
the example environment file and point DATABASE_URL at the postgres service
— inside the app container, localhost is the container itself:
cp .env.example .env.production.local
# Then edit .env.production.local. Set an application-role password, and put
# that same password into DATABASE_URL — nothing derives one from the other:
# APP_DB_PASSWORD=$(openssl rand -hex 32)
# DATABASE_URL=postgresql://counterpoise_app:<that password>@postgres:5432/counterpoise
docker compose --env-file .env.production.local up -d --build
--env-file is required, not optional: Compose reads ${VAR} substitutions in
docker-compose.yml from the shell, a .env file, or --env-file — a
service-level env_file: populates the container but does not feed those
substitutions. Without it, TZ and POSTGRES_PASSWORD silently keep their
defaults.
npm run db:create-test-dbs
Docker only creates the counterpoise database; local development uses counterpoise_dev, which this script creates (along with the databases used by the test suite).
npm run db:seed
This resets the local database, creates a sample admin user with password password, creates a sample book, and seeds it with data. If you want to seed an existing book instead, first create the book, then run npm run db:list-books to find its ID and npm run db:seed -- --book-id <id>.
If you skip seeding, run npm run db:migrate instead to apply the schema — migrations are not applied automatically in local dev.
npm run dev
If you ran npm run db:seed, sign in with admin / password. Otherwise, register an account and create a book.
To explore with realistic data instead of an empty book, click Add demo book
on the books page. It creates a book named "Demo Book" and fills it with the
same sample dataset the seed uses. Unlike npm run db:seed, which resets the
entire database, this only ever writes to the book it just created — so it is
safe to run on an instance that already holds real data, and you can add several.
It writes thousands of rows one at a time, so give it a few seconds.
Counterpoise uses a PostgreSQL database containing all meta tables (users, sessions, books) and book-scoped tables. Book-scoped tables have a bookId foreign key for data isolation. Local development defaults to postgresql://counterpoise:counterpoise@localhost:5432/counterpoise_dev when DATABASE_URL is unset; Docker deployment uses counterpoise via .env.production.local.
The full stack runs as three Docker Compose services:
| Service | Description |
|---|---|
postgres | PostgreSQL 16 database with persistent volume |
app | Next.js standalone server (runs migrations on startup) |
scheduler | PostgreSQL Alpine sidecar — recurring transactions, Plaid sync, backups, pruning, reindex |
The app and scheduler services read secrets from .env.production.local via env_file. Configure these variables:
# .env.production.local
CRON_SECRET=your-cron-secret-here
# Optional — signup control. Leave unset and registration is open only until the
# first account exists, then closes itself.
REGISTRATION_ENABLED=true|false
# Optional — Plaid bank sync (see "Connecting a Bank (Plaid)" below)
PLAID_CLIENT_ID=...
PLAID_SECRET=...
PLAID_ENV=sandbox|production
# Optional — Tiingo security prices
TIINGO_API_KEY=...
# Optional — PostHog analytics
# NEXT_PUBLIC_* values are Docker build args (inlined into the JS bundle at image build)
NEXT_PUBLIC_POSTHOG_KEY=...
NEXT_PUBLIC_POSTHOG_HOST=...
POSTHOG_PERSONAL_API_KEY=... # runtime; used for querying the PostHog API
Set DATABASE_URL in .env.production.local, pointing at the internal
postgres hostname and at the application role — for example
postgresql://counterpoise_app:<app password>@postgres:5432/counterpoise. That
role is created from APP_DB_PASSWORD by
scripts/postgres-init/01-app-role.sh, which runs on first initialization
only: the postgres image skips /docker-entrypoint-initdb.d once the volume
holds a database. Set APP_DB_PASSWORD before the first docker compose up —
setting it later does nothing. The app container refuses to start while
DATABASE_URL still carries the published counterpoise:counterpoise default,
which is in this repository and known to every reader of it.
# Create the persistent data volume (first time only)
docker volume create counterpoise_pgdata
# Build and start all services
docker compose --env-file .env.production.local up -d --build
# Or start just the database (for local dev)
docker compose up -d postgres
The app will be available at http://localhost:3000. Migrations run automatically on container startup via docker-entrypoint.sh.
Rebuild the app image after code changes:
docker compose --env-file .env.production.local up -d --build app
Docker Compose reads env_file only when creating a container. After editing .env.production.local, force-recreate to pick up changes:
docker compose --env-file .env.production.local up -d --force-recreate app scheduler
Note:
docker compose restartwill not re-read the env file — it only stops and starts the existing container with the old environment.
# All services
docker compose logs -f
# Specific service
docker compose logs -f app
# Stop all services (data persists in the pgdata volume)
docker compose down
# Stop and delete the database volume (external volume must be removed separately)
docker compose down
docker volume rm counterpoise_pgdata
The Dockerfile uses a multi-stage build:
node_modules via npm ciThe entrypoint runs Drizzle migrations before starting the Next.js server, so schema changes are applied automatically on deploy.
In a Docker deployment this is not just hardening advice — it is what makes login work at all.
The image runs with NODE_ENV=production, which marks the session cookie
Secure. Browsers refuse to store a Secure cookie that arrives over plain
http://, with one exception: localhost, which they treat as a trustworthy
origin. So the same build behaves differently depending on how you reach it:
| Reached at | Login |
|---|---|
http://localhost:3000 | Works — browsers exempt localhost |
http://192.168.1.50:3000 | Fails silently. Correct password, 200 response, and straight back to the login page |
https://books.example.com | Works |
The middle row has no error message, so it looks like a rejected password. It is
what you get by setting APP_BIND=0.0.0.0 and pointing a phone at the LAN
address. Counterpoise logs a warning when it happens — check docker compose logs app if login is bouncing.
Any of these fixes it:
| Option | What it needs | Notes |
|---|---|---|
| Localhost only | Nothing | No TLS needed. Fine if you use Counterpoise on the machine it runs on. |
| Tailscale Serve | A tailnet | tailscale serve --bg 3000 publishes it at https://<machine>.<tailnet>.ts.net. No open ports, no domain, no certificate management, and it preserves Host. Easiest option for reaching your own instance from other devices. |
| Caddy | A domain, ports 80/443 | Automatic Let's Encrypt certificates from a two-line Caddyfile. Sets Host and X-Forwarded-Proto correctly by default. |
| Cloudflare Tunnel | A domain on Cloudflare | cloudflared dials out, so nothing needs to be opened inbound. |
| nginx + certbot | A domain, ports 80/443 | Works, but needs both proxy headers set by hand — see below. |
Leave APP_BIND at its 127.0.0.1 default when the proxy runs on the same
host; the proxy reaches the app over loopback and nothing else can.
A Caddyfile is the whole configuration:
books.example.com {
reverse_proxy 127.0.0.1:3000
}
nginx needs two headers set explicitly. Its defaults break Counterpoise in two
separate ways — Host becomes the upstream address, which makes every write
fail the cross-origin check, and without X-Forwarded-Proto the app cannot tell
that the original request was HTTPS:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
}
Once TLS is working, set ENABLE_HSTS=true to add a Strict-Transport-Security
header.
Counterpoise was built for a single trusted household on a home LAN. Before exposing it to anything wider, understand these defaults:
Secure under NODE_ENV=production, so login fails
silently over plain HTTP anywhere but localhost. See "Getting HTTPS" above
for the ways to do it. A baseline set of security headers (CSP,
X-Frame-Options, Referrer-Policy, Permissions-Policy) is always sent;
HSTS is added only when you set ENABLE_HSTS=true.REGISTRATION_ENABLED has
three states: unset means open only while no account exists, true means
always open, false means always closed. The unset default is what lets a
fresh install bootstrap its first account and then shut by itself, with no
configuration step and no window where a forgotten default leaves signup open.
To add someone later, set it to true, register them, and unset it again.127.0.0.1 by default, so a reverse proxy is the only
way in. APP_BIND=0.0.0.0 publishes it on your LAN instead — which bypasses
whatever authentication that proxy provides, and, if you reach it over plain
HTTP, silently breaks login. See "Getting HTTPS" above.npm run db:seed creates an admin / password account. Delete or change
it before the instance is reachable by anyone else.127.0.0.1 by default and its bootstrap superuser uses
the password from POSTGRES_PASSWORD. Change it before altering that binding.counterpoise_app from APP_DB_PASSWORD, and the app container
refuses to start if DATABASE_URL still carries the published default./api/cron/* returns 401 unless CRON_SECRET
is set and presented as a bearer token.Host
header. The cross-origin write check in proxy.ts compares the request's
Origin against its Host header. Tailscale Serve preserves Host by
default, so this works out of the box behind it. nginx does not — its
default proxy_set_header Host $proxy_host replaces Host with the
upstream address — and a proxy on that default will 403 every write with an
opaque "Cross-origin request rejected". Configure proxy_set_header Host $http_host; (or equivalent) if you front Counterpoise with nginx or a
similar proxy.The scheduler container dumps the database hourly to ./backups, prunes dumps
older than 30 days, and reindexes monthly.
# Take a backup now
docker exec counterpoise-scheduler-1 sh -c \
'pg_dump -Fc "$DATABASE_URL" > /backups/manual-$(date +%Y%m%d-%H%M%S).dump'
# List a dump's contents without restoring
pg_restore --list backups/<file>.dump
# Full restore (drops and recreates all objects) — stop the app first
docker compose stop app
pg_restore --clean --if-exists -d "$DATABASE_URL" backups/<file>.dump
docker compose start app
Everything above runs on one disk. Hourly dumps beside the database they came from protect you from a bad migration or a mistaken delete — not from disk failure, theft, or ransomware, all of which take the database and every dump together. Nothing in this repo can fix that for you; it needs a second place.
Two ways, either is fine:
A whole-disk backup service already covering the host — Backblaze, Time
Machine to a separate drive, or equivalent. Nothing to configure here, as
long as ./backups is not in an exclusion list. Check that it is actually
being picked up rather than assuming it.
A scheduled copy of the newest dump to cloud storage or another machine.
Run this from the host's crontab, not the scheduler container: that
container is postgres:16-alpine and has no rclone. Use an absolute path
to your checkout — /backups is the path inside the container, and cron
has no working directory to speak of:
0 5 * * * rclone copy "$(ls -t /srv/counterpoise/backups/counterpoise-*.dump | head -1)" remote:counterpoise/
restic, rsync over SSH, or aws s3 cp all work the same way. Putting the
copy in the scheduler's crontab instead means building your own image with
the tool installed — the stock one cannot do it.
Whichever you pick, keep version history. A backup that mirrors the current state one-for-one will faithfully replicate a corruption or an encryption event to your only other copy. Thirty days of retention turns that from a disaster into an inconvenience.
You do not need to verify the dumps yourself — the scheduler already does, on every one (see Monitoring below). What it cannot do is put them somewhere else.
Counterpoise verifies each dump with pg_restore --list and records the outcome
of every scheduled job to backups/status/. The app surfaces stale or
unverified jobs in the navbar — silently, until something needs attention.
That design assumes you use the app. It detects a broken backup job while everything else works, but it cannot tell you the host is switched off, because it is running on that host. On a machine you open regularly that gap is covered by you noticing.
If you deploy this somewhere you don't look at daily, add an external dead-man
switch — healthchecks.io or similar — by appending a ping to each cron line in
docker-compose.yml. That is the only layer that still reports when the whole
host is down.
The scheduler container runs all cron jobs on a postgres:16-alpine image (giving it access to pg_dump, reindexdb, and wget):
| Job | Schedule | Description |
|---|---|---|
| Recurring transactions | Hourly | Calls /api/cron/recurring authenticated with CRON_SECRET |
| Plaid sync | Every 6 hours | Calls /api/cron/plaid-sync for all linked asset/liability accounts |
| Security price sync | Tue–Sat 6am ET | Calls /api/cron/price-sync to fetch Tiingo end-of-day prices |
| Database backup | Hourly, 6am–9pm | pg_dump to backups/counterpoise-<timestamp>.dump |
| Backup pruning | Daily at 4am | Deletes .dump files older than 30 days |
| REINDEX | 1st of month at 3am | reindexdb "$DATABASE_URL" |
Manual trigger:
curl -H "authorization: Bearer ${CRON_SECRET}" http://localhost:3000/api/cron/recurring
Bank sync is optional, and off until PLAID_CLIENT_ID and PLAID_SECRET are
set — isPlaidConfigured() is false without them, so sync fails closed rather
than reaching a live institution.
Sign up at dashboard.plaid.com. Your client_id
and per-environment secrets are under Developers → Keys.
Plaid has two environments: Sandbox, which serves fake institutions and
fake transactions, and Production, which connects real banks. There is no
longer a Development environment — Plaid retired it, so PLAID_ENV takes only
sandbox or production.
Production is not gated behind a sales call for a personal deployment. Developers signing up in the US or Canada get the Trial plan: free, real production data, auto-approved for most applicants, capped at 10 connected Items. That is usually enough for one household's banks. (The older Limited Production tier closed to new signups on 15 April 2026.)
Use the Sandbox secret in .env.local and the Production secret in
.env.production.local. .env.example explains why that separation is not
optional: a production secret in .env.local means npm run dev reaches real
banks and bills real API requests, and the separate counterpoise_dev
database does nothing to prevent it — it bounds writes, not outbound calls.
Counterpoise syncs against a stored access token per institution, but it does
not run Plaid Link itself. scripts/plaid-link.ts produces the token:
npm run plaid:link # sandbox, via .env.local
# Or, to connect a real bank with the deployment's credentials:
npx tsx --env-file=.env.production.local scripts/plaid-link.ts
It prints a Plaid-hosted URL. Open it in any browser, log in to the bank, and
the script prints an Item ID and an Access Token when the session
completes. In Sandbox, log in to any institution with user_good /
pass_good.
It stops waiting after ten minutes and prints the command to resume. Use that rather than re-running plain: a fresh link token stops watching the session you opened, so an Item you had already created at the bank would sit on your Plaid plan with no token to exchange for it.
The script uses Hosted Link, where
Plaid serves the Link UI on its own domain, so there is nothing to run locally
and no redirect URI to register. That matters for OAuth institutions — Chase,
Wells Fargo, US Bank — which require a redirect URI that is HTTPS and
registered in the Plaid dashboard, and so cannot be completed against a
http://localhost page at all.
Go to Sync → Manage Sync Tokens, then Add Token. Enter the institution name, and paste the Item ID and Access Token. Counterpoise fetches the institution's accounts, and Assign Accounts maps each one to a Counterpoise account.
From then on the scheduler sidecar syncs every six hours, staging
transactions for reconciliation rather than writing them to the ledger
directly. Review them on the Sync page.
An access token does not expire. Treat it as a credential: it reads the connected account's transactions until revoked from the Plaid dashboard.
Import data from Moneydance JSON exports:
# Dry run (recommended first)
npx tsx scripts/import-moneydance/index.ts path/to/export.json --book-id <existing-book-id> --dry-run
# Full import
npx tsx scripts/import-moneydance/index.ts path/to/export.json --book-id <existing-book-id> --verbose
Create the destination book first in the UI, or use npm run db:seed for a sample seeded book. Use npm run db:list-books to find the book ID before importing.
Imports accounts, payees, transactions, investment transactions, security prices, stock splits, and recurring reminders. See scripts/import-moneydance/README.md for details.
Simple Mode:
Result:
Journal Entry Mode:
The system will automatically show when it's due and allow one-click processing.
| Account Type | Normal Balance | Increase | Decrease |
|---|---|---|---|
| Asset | Debit (+) | Debit | Credit |
| Liability | Credit (-) | Credit | Debit |
| Equity | Credit (-) | Credit | Debit |
| Income | Credit (-) | Credit | Debit |
| Expense | Debit (+) | Debit | Credit |
Buying groceries with credit card:
Paying off credit card:
Receiving salary:
npm run dev # Start development server
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint
npm test # Run unit tests (Vitest)
npm run test:ui # Open Vitest UI
npm run test:coverage # Generate coverage report
npm run test:e2e # Run Playwright E2E tests
npm run db:generate # Generate a migration from /db/schema.ts into /db/migrations
npm run db:migrate # Apply pending migrations
npm run db:create-test-dbs # Create dev + per-worker test databases (one-time setup)
npm run db:list-books # List books and their IDs
npm run db:seed -- --book-id 2 # Full reset + seed sample data for a specific book
npm run mcp:dev # Start the MCP server (stdio)
npm run plaid:link # Mint a Plaid access token for one bank (sandbox)
npx drizzle-kit studio # Open Drizzle Studio (database GUI)
For book schema changes, use this workflow:
/db/schema.tsnpm run db:generatenpm run db:migrate/db/migrations/meta/Migrations are NOT auto-applied by getDb(). Use runMigrations() explicitly in scripts; seed and test helpers handle migrations automatically.
/app
/page.tsx # Home / book list
/login, /register, /account # Auth pages
/b/[bookId]/ # Book-scoped pages
/page.tsx # Dashboard
/accounts, /transactions # Core accounting
/securities, /recurring # Investment & recurring
/payees, /sync # Payees & bank sync
/reports, /search # Financial reports & search
/api/
/auth/ # Authentication
/books/ # Book management
/b/[bookId]/ # Book-scoped API routes
/cron/ # Cron endpoints (recurring, plaid-sync, price-sync)
/components
/ui # Reusable UI components
/accounts, /transactions # Feature components
/securities, /sync, /layout # Domain components
/reports # Financial report components
/db
/schema.ts # Unified database schema (meta + book-scoped tables)
/index.ts # Database connection (getDb)
/seed.ts # Sample data
/lib
/accounting.ts # Accounting helpers
/investments.ts # Investment calculations
/formatters.ts # Display formatters
/api-auth.ts # API authentication
/reports.ts # Financial report logic
/hooks
/useBookId.ts # Client hooks (also useIsMobile, useRegisterShortcuts)
/mcp
/server.ts # MCP server (AI access to accounting data)
MIT — see LICENSE.
Counterpoise is developed as a personal project and is not accepting pull requests, feature requests, or bug reports. That is not unfriendliness — it is the point of publishing it.
Fork it and make it yours. The repository is built for exactly that: CLAUDE.md
is a complete machine-readable contract for the codebase, so your own AI agents
can pick it up and build on it without a human explaining the architecture
first. The .claude/skills/ directory ships the maintainer's own workflows as
worked examples.
If you want to track upstream changes, add this repository as a second remote
and cherry-pick what you want. Releases are tagged vX.Y.Z.
8 commits
TypeScript
99.1%