Self-hosted retirement & net-worth tracker. Single-user, Docker + SQLite.
2.8K
A self-hosted retirement / net-worth tracker. Single-user, password protected, runs as one Docker container with a SQLite file for storage.
docker exec into the container and run flask reset-password — see Account recovery below. No email, no SMTP setup, no support tickets.generate_password_hash (PBKDF2-SHA256, salted).cp .env.example .env
# edit .env and set a real SECRET_KEY (any long random string)
docker compose up -d --build
Then open http://localhost:5000 and follow the setup wizard.
Your data lives in ./data/retirement.db on the host (mounted as a volume), so it survives container rebuilds. Back that file up like you would any other sensitive personal file.
If you already have this running with data in it: just rebuild and restart (docker compose up -d --build). The app checks its own database schema on startup and adds any new columns/tables it needs automatically — your accounts, inheritance entries, and history are never wiped or reset by an upgrade.
This is a single-user app with no email/SMTP setup, so password reset works differently than you might be used to: server access is the recovery mechanism. If you can docker exec into the container, you can always get back in — that's the same level of access you already need to deploy this app in the first place, and the same level of access that already lets you read the raw SQLite file directly if you really wanted to.
docker exec -it <container_name> flask reset-password
This prompts you for a new password (hidden input, typed twice to confirm) and sets it directly — no need to know the old one. If two-factor authentication is enabled, it gets cleared too, since if you're locked out you may have lost your authenticator device as well as your password. You can re-enable 2FA from Settings after logging back in.
Don't know your container's name? docker ps will show it.
This is also why optional 2FA (Settings → Two-factor authentication) is just that — optional, additional security, not a replacement for the password. There's deliberately no self-service "forgot password" web flow gated behind a recovery code or backup codes you'd have to remember to save somewhere: for a single-user self-hosted tool, the CLI command above is simpler, has no setup step, and can never lock you out the way a lost recovery code could.
This has been hardened against the OWASP Top 10, with the app-level pieces below already in place. If you're exposing this to the internet (e.g. nginx reverse proxy + Cloudflare), the deployment-level steps after that matter just as much — the app can't protect itself from a misconfigured edge.
What's implemented in the app:
unsafe-inline for scripts), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and HSTS when served over HTTPSonsubmit="confirm('...')" handlers, and a name containing '); could break out of the JS string and execute arbitrary script (HTML-escaping doesn't protect against this — the browser decodes entities before handing inline-handler content to the JS engine). Everything now uses data-confirm/data-auto-submit attributes read by an external script instead, which can't be escaped out of this way<img> tags or link tricksdocker logs) — login successes/failures, account lockouts, MFA changes, password changes, data export/import/full-resetHttpOnly always, SameSite=Lax always, Secure when SECURE_COOKIES=true (see below)ProxyFix middleware so the app correctly recognizes HTTPS/client IP when sitting behind nginxSECRET_KEY — the app refuses to start if SECRET_KEY is not set, with a clear error explaining how to generate oneWhat you need to do if exposing this to the internet:
SECRET_KEY to a real random value — the app will not start without it. Generate one with:
python -c "import secrets; print(secrets.token_hex(32))"
SECURE_COOKIES=true once you have real HTTPS in front of this. Leave it false for plain-HTTP LAN use — a Secure cookie is silently dropped by the browser over HTTP and you won't be able to log in./login and /login/verify as a second layer in front of the app's own rate limiting — the app's limiter is per-process and IP-based, which is reasonable for one person but is no substitute for edge-level protection against a real attacker.What's accepted as out of scope, deliberately:
All configuration is via environment variables (set in .env or passed to docker run/docker compose).
| Variable | Required | Default | Description |
|---|---|---|---|
SECRET_KEY | Yes | — | Long random string used to sign session cookies. App refuses to start without it. Generate with python -c "import secrets; print(secrets.token_hex(32))" |
DATABASE_PATH | No | /data/retirement.db | Full path to the SQLite database file inside the container. Change if you want to mount the volume at a different path |
SECURE_COOKIES | No | false | Set to true once this is behind real HTTPS. When true, session cookies get the Secure flag and are only sent over HTTPS. Leave false for plain-HTTP LAN use — a Secure cookie is silently dropped over HTTP and you won't be able to log in |
The /data directory inside the container holds your SQLite database. When mounting it as a host volume, restrict access to the app user only:
mkdir -p ./data
chmod 700 ./data
Without this, any process or user on the host with read access to the volume path can read the raw database file directly (passwords are hashed inside it, but all financial data is not). If you're running in a rootless Docker setup this is handled automatically; check with ls -la ./data.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python run.py
Visits http://localhost:5000. In dev mode the database defaults to /data/retirement.db — change DATABASE_PATH in .env if you'd rather it sit inside the project folder, e.g. DATABASE_PATH=./data/retirement.db.
app/
__init__.py # app factory, first-run / setup-complete redirects, schema auto-migration
models.py # User, Profile, Account, Snapshot, Inheritance, Child
projections.py # pure-Python compounding engine (no Flask deps — easy to unit test)
data_io.py # export/import field mapping + validation (also framework-light)
routes/
auth.py # create-account (first run only), login, logout
setup.py # onboarding wizard: profile -> accounts -> inheritance
dashboard.py # main view + /api/projection JSON for the charts
accounts.py # ongoing account CRUD
inheritances.py # ongoing inheritance CRUD
history.py # record actual balances that override the projection
kids.py # children + their JISA/JSIPP accounts, kept out of the main figures
settings.py # assumptions, password, theme, export/import/reset
templates/, static/ # server-rendered Jinja2 + vanilla CSS/JS, Chart.js via CDN
run.py
Dockerfile / docker-compose.yml
It's deliberately a single Flask app with server-rendered pages rather than a separate frontend build — simplest possible thing to keep running long-term in one container.
For each account, each year: balance = balance × (1 + growth_rate) + contribution (contribution stops at retirement if you've ticked that option, and contributions can themselves grow year-on-year if you set a contribution growth rate — handy if you expect contributions to rise with salary or inflation).
If you've recorded an actual balance for an account at a given age (via History), that figure replaces the computed one for that age, and every subsequent year compounds forward from it instead of from the original assumption. Ages before your current age with no recorded snapshot simply aren't shown — there's no way to compute history backwards, only to record it.
Inheritance is added in the year you specify, at gross_amount × share_percent. If you don't assign it to an account it's tracked as unallocated cash and shown as its own line on the chart.
Each account has a "counts as a retirement asset" flag. Total net worth sums every account. Retirement assets (and the withdrawal capacity calculated from it: retirement assets × withdrawal rate) only sums accounts with that flag ticked — untick it for property/home equity so it doesn't inflate the income figure you're actually planning to live on.
user_id, so multi-user support mostly just needs a normal sign-up flow instead of the current "only one account can ever be created" guard in auth.py — but for a personal tracker, one password is usually all you want.dashboard.html loads Chart.js from jsDelivr. If you're running this somewhere with no outbound internet access, download chart.umd.min.js yourself into app/static/js/ and change the <script> tag in dashboard.html to point at it.currency field to Profile and swap the £ literals in templates/JS for it if you need other currencies.data/retirement.db) is the full source of truth (including your password hash) if you want a complete file-level backup instead.Content type
Image
Digest
sha256:3131f2291…
Size
38.8 MB
Last updated
6 days ago
docker pull simonbrooker/retireme