Wicker Money 是一款自行託管的個人理財軟體:核心精簡,可安裝外掛,為希望將財務資料存放於自家伺服器的使用者而設計。
AI 翻譯
Wicker Money is a self-hosted personal finance app: a thin core plus installable plugins, built for people who want their financial data on their own server.
金融TypeScript · CSSGitHub @WickerMoney上線中營運者未確認
試用過了嗎? 點一下就好,不需要登入。
個人理財應用程式,插件架構讓您能編織所需功能。
AI 翻譯
Personal Finance Application, plugin architecture to allow you to weave together what you need.
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Self-hostable personal finance, rebuilt as a thin core plus installable plugins.
The core owns identity, money movement and the app shell. Everything that
interprets money — budgets, forecasting, net worth, FIRE, importers — is a
plugin built against @wickermoney/plugin-sdk. A fresh install is useful on its
own; bundled plugins ship enabled and are built against the same SDK a
third-party plugin would use. Plugin code is fully trusted today (see
Plugins), so third-party plugin install is not supported yet.檢視完整 README ↗Self-hostable personal finance, rebuilt as a thin core plus installable plugins.
The core owns identity, money movement and the app shell. Everything that
interprets money — budgets, forecasting, net worth, FIRE, importers — is a
plugin built against @wickermoney/plugin-sdk. A fresh install is useful on its
own; bundled plugins ship enabled and are built against the same SDK a
third-party plugin would use. Plugin code is fully trusted today (see
Plugins), so third-party plugin install is not supported yet.
How each feature works under the hood is in DEVELOPMENT.md.
You need Docker. The compose sample runs PostgreSQL 16 for you, or you can
bring your own. Copy
docker/docker-compose.sample.yml to
docker-compose.yml and review it top to bottom (role names especially, per
.env.example). Put the three secrets it requires in a .env
beside it, then start it:
# .env
POSTGRES_PASSWORD=<a long random password>
APP_DB_PASSWORD=<a different long random password>
AUTH_SECRET=<output of: openssl rand -base64 48>
docker compose up -d
Open http://<host>:8180 (set HOST_PORT to change it) and create an
account. The first account on an instance is its owner, unless you set
BOOTSTRAP_OWNER_EMAIL (see Owners and members); set it
in .env before the first start if anyone else can reach the host. Once everyone who
needs an account has one, add REGISTRATION_ENABLED=false to the
wickermoney service's environment and run docker compose up -d again.
Serve it over HTTPS before you put real data in it; see
Behind an HTTPS reverse proxy.
To work on the code instead, see DEVELOPMENT.md.
This is the long form of the quick start: running the image by hand, HTTPS,
and what to watch for. One image holds the API, the built web app and the bundled plugins. The API serves
the UI itself (WEB_DIST_DIR, set to /app/web in the image), so there is one
process on one port (8080) and one origin, which the refresh cookie and
same-origin plugin loading depend on.
# Build it, or pull a released one: ghcr.io/wickermoney/wicker-money:<version>
docker build -f docker/Dockerfile -t wickermoney .
# 1. Migrate, as the database owner. The image does not migrate on start.
docker run --rm --env-file .env wickermoney node dist/db/cli.js up
# 2. Run it.
docker run -d --name wickermoney --env-file .env -p 8080:8080 wickermoney
The Quick start does the same with Docker Compose and its own PostgreSQL container.
It takes the same variables as .env.example. The image sets NODE_ENV=production,
so the API refuses the development credentials, and localhost in DATABASE_URL
means the container itself, not your machine.
[!WARNING] Serve Wicker Money over HTTPS. Plain HTTP is for
localhostand a first smoke test only.Wicker Money holds your financial data and your session. Over plain HTTP on a LAN address (
http://192.168.x.x:8080):
- Credentials and the refresh token cross the network in clear text. Anyone on the same network segment can read them.
- The browser treats the page as an insecure context and withholds HTTPS-only APIs.
crypto.randomUUIDis the one that has already broken a page (the Categories rule builder, 2026-09-23). Browser code must usenewUuid()fromapps/web/src/lib, and an ESLint rule enforces that, but clipboard,crypto.subtleand service workers are missing too.- You have to weaken the cookie (
COOKIE_SECURE=false), or the browser discards the refresh cookie and signs you out on every reload.- Chromium logs a
Cross-Origin-Opener-Policyconsole warning, because it ignores that header on an insecure origin. It is harmless and disappears over HTTPS. (v0.6.0and earlier also logged anOrigin-Agent-Clusterwarning, once per plugin frame and over HTTPS as well. That had a different cause, and the API no longer sends that header.)
Terminate TLS in a reverse proxy (Caddy, Nginx Proxy Manager or Traefik) and forward to the container's single port, 8080. There is no separate UI port.
TRUST_PROXY=true, or every user shares the proxy's address for the
per-address auth limits (AUTH_RATE_LIMIT_MAX) and the API rate limit for
requests that carry no valid token.COOKIE_SECURE unset. It defaults to true in production.TRUST_PROXY=true the API believes
X-Forwarded-For, so a client that reaches the container directly can spoof its
address and dodge the rate limits. Put the proxy on the same Docker network, or
bind the port to loopback (127.0.0.1:8080:8080).Host (Caddy and Nginx Proxy Manager do by
default; plain Nginx needs proxy_set_header Host $host). State-changing auth
requests are rejected when Origin does not match it (origin_mismatch).Strict-Transport-Security with includeSubDomains. Once a browser
has seen it over HTTPS, it refuses plain HTTP for that host and its subdomains
for a year (Helmet 8's default max-age). Serve Wicker Money on its own subdomain (wickermoney.example.com), not
on an apex domain that also hosts HTTP-only services.wickermoney.example.com {
reverse_proxy wickermoney:8080
}
Set COOKIE_SECURE=false. Expect the limitations in the warning above.
PORT to change the host port. PORT is the port the API listens
on inside the container. Change the host side of the mapping instead
("8180:8080"), or nothing answers.curl healthcheck in compose. The image is bookworm-slim and has no
curl. Omit the healthcheck block; the image's built-in one uses node.The container's health check calls /healthz. Use /readyz (which also checks the
database) for a load balancer. Both report the running version (and gitSha,
when the image was built from a tag) -- handy for confirming what actually
redeployed without cross-checking the image tag. core.app_deployments keeps
one row per boot with the same information, for after the fact.
The database is called wickermoney.
As a superuser, once:
CREATE ROLE wickermoney LOGIN PASSWORD 'pick-something' CREATEROLE;
CREATE DATABASE wickermoney OWNER wickermoney;
CREATEROLE is required: migration 005 creates the wickermoney_app role. Without
it the migration fails with "Only roles with the CREATEROLE attribute may
create roles." Verify before migrating:
SELECT current_database(), current_user,
(SELECT rolcreaterole FROM pg_roles WHERE rolname = current_user) AS can_create_roles;
Read the release's entry in CHANGELOG.md first, and back up
the database (pg_dump -Fc) before migrating. Then pull the new image and
migrate before starting it, exactly as on first install
(node dist/db/cli.js up). With the compose sample, docker compose pull and
docker compose up -d do both, because its migrate service runs first.
Migrations only move forward in practice. Whether you can go back to the previous image without restoring that backup depends on the release:
| Release | Migrations | Going back to the previous image |
|---|---|---|
v0.6.1 | 033 (has a down that changes nothing) | Run node dist/db/cli.js down once with the new image, then start the old one (the policy rewrite stays; it returns the same rows) |
v0.6.0 | 029, 030, 031, 032 (all have a down) | Run node dist/db/cli.js down four times with the new image, then start the old one (this deletes everything in the debt payoff plugin, and the owner-role and erase functions) |
v0.5.0 | 027, 028 (both have a down) | Run node dist/db/cli.js down twice with the new image, then start the old one (this deletes account allowances) |
v0.4.2 | none | Run the old image as it is |
v0.4.1 | none | Run the old image as it is |
v0.4.0 | 025, 026 (both have a down) | Run node dist/db/cli.js down twice with the new image, then start the old one |
v0.3.0 | 023 (has a down), 024 (no down) | Restore the backup |
v0.2.1 | none | Run the old image as it is |
v0.2.0 | 021 (no down: it adds an enum label PostgreSQL cannot remove), 022 | Restore the backup |
After upgrading to v0.4.0, read Owners and members:
accounts registered before it are all owners. After upgrading to v0.4.1,
open Categories → Run setup again to add the new starter categories to an
account that is already set up; existing categories are kept. After
upgrading to v0.4.2, an instance whose AUTH_SECRET or database URL still
contains a placeholder such as change-me or CHANGE_ME refuses to start in
production until you replace it; the
steps are under Placeholder credentials
and in the changelog. After upgrading to v0.5.0, nothing changes for
existing accounts; to name the owner of a new instance, set
BOOTSTRAP_OWNER_EMAIL (see Owners and members).
After upgrading to v0.6.0, a script that downloads the data export must now
ask for a ticket first (see the changelog), and behind a reverse proxy that
compresses responses you should stop the proxy compressing this upstream, as the
API now does it itself. After upgrading to v0.6.1, nothing changes for existing
accounts; an owner is now asked to confirm in Settings before switching on a
third-party plugin, and the upgrade runs one more migration (033), which
changes no access.
Per-release
notes are also on the docs site under Upgrading.
The first account on an instance is its owner; every account registered after it is a member. Members use the app normally, with their own data. Only an owner can administer the instance, which today means turning plugins on and off and changing other accounts' roles. The server checks the role on every owner-only request, so changing it takes effect on that person's next request, with no sign-out.
Who becomes the owner is set by one optional setting, alongside the one that closes registration:
| Variable | Default | Effect |
|---|---|---|
BOOTSTRAP_OWNER_EMAIL | unset | When set, the account registered with this address (case-insensitive) becomes the owner, whether it registers first or after other people; everyone else is a member. When unset, the first account to register is the owner. |
REGISTRATION_ENABLED | true | Set to false once the accounts you need exist. It takes precedence: with registration closed, nobody can register, including BOOTSTRAP_OWNER_EMAIL. |
With BOOTSTRAP_OWNER_EMAIL unset, whoever registers first on a fresh instance
owns it, so on a server other people can reach, register your own account
straight away or set the variable first. In production the API logs a warning at
start-up when the instance has no accounts, registration is open and the
variable is unset. Setting it never creates a second owner while one exists
(the address then registers as a member) and never changes an existing account.
Email addresses are not verified when someone registers, so anyone who knows the
address could register it before you do; the variable keeps strangers from
claiming a fresh instance by accident, it is not a login check. Register
promptly, then close registration.
Changing roles. An owner opens Settings → People, which lists every account (email, when it joined, role) with a role select for each. Making someone an owner applies at once and is how an instance gets a second owner; registration still never makes one by itself. Making an owner a member asks for confirmation first. An instance always keeps at least one owner: the last owner cannot be demoted, and an owner can step down only while another owner remains. Members do not see the section. The role applies on the affected person's next request; their sessions are not ended.
If you cannot sign in as an owner at all, the screen cannot help, and SQL still
works as the database owner (DATABASE_OWNER_URL):
-- who is an owner
SELECT email, role, created_at FROM core.users ORDER BY created_at;
-- demote one account (or set 'owner' to promote)
UPDATE core.users SET role = 'member' WHERE email = '[email protected]';
-- keep only the earliest account as owner
UPDATE core.users SET role = 'member'
WHERE role = 'owner'
AND id <> (SELECT id FROM core.users ORDER BY created_at, id LIMIT 1);
Before v0.4.0 every account was registered as an owner, and upgrading does
not change existing accounts. If several people signed up on your instance
before then, check the list above. If every owner is demoted, the next account
to register becomes the owner. Set REGISTRATION_ENABLED=false once the
accounts you need exist.
Settings → Plugins lists every bundled plugin: what it adds, its version, and whether it is on, off or failed to load (with the reason). An owner can turn any plugin on or off for the whole instance, and the app applies it straight away: the plugin's pages, sidebar entry and dashboard widgets appear or disappear without a reload, and other open tabs catch up when they regain focus. Turning a plugin off deletes nothing. Its tables, rows and database role stay, and turning it back on brings everything back. Plugins in the same area, such as two budgeting approaches, can be on together.
Where a plugin runs decides how far it is trusted. Insights, Spending
Trends and Upcoming run in a sandboxed frame and reach your data only through the host, with
the grants their manifest lists. The other bundled plugins still run inside the
app, in its own origin, with the same access to your data as you have; they ship
in the image and are reviewed with it. There is no install flow or catalog for
third-party plugins: an operator can add one by hand (a manifest with an
integrity pin in PLUGIN_MANIFEST_DIR, its origin in PLUGIN_REMOTE_ORIGINS),
and it then runs only in a frame, with no server-side code. The sandbox has known
gaps and is not independently reviewed, so only add a plugin you have read. See
SECURITY.md.
A self-hosted instance has no mail server, so there is no reset link to send. Reset from the machine running the API instead:
pnpm --filter @wickermoney/api reset-password [email protected]
# or, to have one generated and printed once:
pnpm --filter @wickermoney/api reset-password [email protected] --generate
The password is read from stdin rather than taken as an argument, so it does not land in shell history or the process list. Every outstanding session is revoked, the same as changing a password from inside the app.
This grants no access the operator did not already have — running it requires
the database credentials in .env. What it saves is producing a correct
Argon2id digest with the application's own parameters by hand.
v0.6.1 is the current release, a small patch on v0.6.0: Settings asks an
owner to confirm before switching on a third-party plugin, the Transactions table
fits narrower windows, the browser console and DevTools no longer warn about
the plugin frame, and one policy gets the per-statement user lookup. v0.6.0
brought the isolation work: plugin front-end code can now run in a sandboxed frame, behind plugin-scoped tokens that the server holds to a plugin's
own grants; Insights and Spending Trends run that way, and an operator can add a
third-party UI plugin by hand, as an experimental feature with no install flow.
It also adds a password-confirmed data export, a danger zone for erasing data,
owners changing roles from Settings, a debt payoff plugin, a configurable import
cap and database limits, and an accessibility and performance sweep, on top of
v0.5.0's plugin platform hardening and account allowances, v0.4.2's phone
layouts and v0.4.0's plugin manager, owner and member roles and matching from
the Transactions page. Wicker Money is pre-1.0 with a single maintainer, so the
plugin API can still change between minor versions.
CHANGELOG.md has every release and ROADMAP.md
what is next.
The app, its API and the bundled plugins are licensed under
AGPL-3.0-only. @wickermoney/plugin-sdk and @wickermoney/ui-kit,
the packages a plugin builds against, are licensed under
Apache-2.0, so a plugin can choose its own
license. The Wicker Money name and logo are covered by the
trademark policy.