Deployment¶
Four ways in, plus NAS platforms, most of which take the Compose file. All run the same image and need somewhere to write. The config file is yours on Kubernetes; everywhere else the setup wizard writes it.
| Path | For | Database |
|---|---|---|
| Docker Compose | One machine, a NAS, a home server | SQLite |
| Plain Kubernetes | A cluster, without Helm | SQLite |
| Helm | A cluster, with Helm | SQLite |
| Helm with Postgres | A cluster, sharing your Postgres | Postgres |
There is no Redis, no queue and no secrets service. Sessions are database-backed tokens and the price feed needs no credentials, which is what makes this a two-file deployment.
Four things everyone gets wrong¶
Read these before picking a path — they apply to all four, and each one causes a failure that looks like something else.
Finish setup before anyone else can reach it. Until the first account exists, whoever opens the app first runs the setup wizard and becomes its administrator. Run it before the port or the Ingress is open to anyone else.
Set the timezone. Containers run UTC. Every date decision — which financial year today is in, whether a trade is dated in the future — is made in the configured zone, so leaving it unset puts the app a day behind for most of an Australian morning.
Set trusted_proxies behind a reverse proxy. It is how the app learns what
a proxy hides: whether the visitor's connection was encrypted
(X-Forwarded-Proto) and which address it came from (X-Forwarded-For).
Without it, a proxy that terminates TLS makes every request look like plain
HTTP, so with cookie_secure: true the login page warns that you are on plain
HTTP when you are not, and passkeys are not offered; and every sign-in and
session is recorded against the proxy's address. Login lockout does not depend
on it, because it counts failures per account from every address. Set it to
your proxy's address or CIDR and nothing wider: a caller whose address is
believed can choose the address recorded against their sign-ins, and a forged
X-Forwarded-Proto could claim an encrypted connection that is not.
Set cookie_secure: true once TLS is in front. Until then the session
cookie travels in clear. The Helm chart refuses to render an Ingress without
it rather than letting that happen quietly.
Storage permissions¶
The image runs as uid 1000, and the volume must be writable by it.
- Kubernetes —
fsGroup: 1000on the pod. The manifests and the chart both set it. Without it the volume arrives owned by root and the app cannot create its database. - Compose — a named volume inherits the image's ownership and just
works. A bind mount does not: run
chown -R 1000:1000 ./dataon the host first.
Docker Compose¶
Everything is in deploy/compose/.
cd deploy/compose
docker compose up -d
Open http://localhost:8000 and the setup wizard takes it from there — no configuration file has to exist first.
The wizard writes config.yaml to the stocktake-config volume, mounted at
/config. The config.yaml beside the compose file is the annotated default,
there to read rather than mounted; every setting and its default is also in the
configuration reference.
docker compose logs -f app
docker compose pull && docker compose up -d # upgrade
docker compose exec app python -m app.recover --list # locked out
Backing up is the same on every platform: Backing up and restoring.
Plain Kubernetes¶
One file, five resources, no Helm:
deploy/kubernetes/stocktake.yaml.
kubectl create namespace stocktake
# edit the seven CHANGE ME markers first
kubectl apply -f deploy/kubernetes/stocktake.yaml
kubectl -n stocktake rollout status deploy/stocktake
The markers are: the release to run, the timezone (twice — config and the
TZ environment variable), cookie_secure and trusted_proxies for when TLS
is in front, the StorageClass if your cluster has no default, and the Ingress
host. Delete the Ingress if you reach the Service another way. Upgrading is
changing the release, after a backup.
strategy: Recreate is not a preference
The volume is ReadWriteOnce, so a second pod cannot attach it while the first holds it — a RollingUpdate deadlocks on Multi-Attach and hangs until it times out. More importantly, SQLite takes one writer: two pods on one file is corruption, not contention.
Helm¶
helm install stocktake oci://ghcr.io/jarrodruggiero/charts/stocktake \
--namespace stocktake --create-namespace \
--set timezone=Australia/Melbourne
Each release publishes the chart with the same version as the app, and the
chart runs that release's image. Without --version Helm takes the newest;
add --version 0.68.0 to choose one.
Defaults give you SQLite on a 2 GiB PersistentVolume, which is the right choice for almost everyone.
Behind an ingress, all three of these go together:
ingress:
enabled: true
className: nginx
host: stocktake.example.com
tls:
- secretName: stocktake-tls
hosts: [stocktake.example.com]
auth:
cookieSecure: true
trustedProxies: ["10.42.0.0/16"] # your ingress controller's pod CIDR
The chart fails to render if you enable the ingress without
auth.cookieSecure. That is deliberate: a warning gets ignored, and the person
deploying it almost certainly believes TLS is handling the cookie.
Anything the chart does not model goes in extraConfig, which is merged into
config.yaml — so any setting the app supports is reachable without the chart
needing to know about it:
extraConfig:
imports:
max_upload_mb: 25
The data PVC carries helm.sh/resource-policy: keep, so helm uninstall does
not take your portfolio with it.
Helm with Postgres¶
Choose Postgres when you already run one and would rather keep everything in it. Otherwise SQLite is simpler and enough. It is still one replica: the import preview, the statement mapper and the price feed each live in the one process, so a second replica would lose an import whose preview and commit reached different pods.
The chart ships no bundled Postgres, on purpose. A database that appears
and disappears with helm uninstall is a way to lose your data. Point it at
one you run and back up.
kubectl -n stocktake create secret generic stocktake-db \
--from-literal=password='...'
helm install stocktake oci://ghcr.io/jarrodruggiero/charts/stocktake \
--namespace stocktake \
--set database.type=postgres \
--set database.postgres.host=postgres.databases.svc \
--set database.postgres.name=stocktake \
--set database.postgres.user=stocktake \
--set database.postgres.existingSecret=stocktake-db
The password comes from a Secret you create and never from values — values
end up in helm get values, in CI logs and in git.
The app creates its own schema at startup; the database and role need to exist first. The deployment strategy stays Recreate: an upgrade stops the old pod before it starts the new one, for the one-replica reasons above.
Backing up and restoring¶
Three things, on every platform, and nothing else:
| What | Where |
|---|---|
| The database | /data/stocktake.db on SQLite, or the Postgres database you named |
| Formats you added | /data/formats/: broker and statement formats installed from Imports & exports |
| The configuration | /config/config.yaml, which the setup wizard wrote; on Kubernetes, your manifest or Helm values |
/scratch is never part of a backup. Its page images and CSV rows are minutes
old, and the app sweeps them.
SQLite¶
Copy the database with SQLite's backup API, which gives a consistent copy while
the app runs. The image has no sqlite3 command, so Python does it:
docker compose exec app python -c "import sqlite3; sqlite3.connect('/data/stocktake.db').backup(sqlite3.connect('/data/backup.db'))"
docker compose cp app:/data/backup.db ./stocktake-backup.db
docker compose cp app:/data/formats ./stocktake-formats # there once a format is added
docker compose cp app:/config/config.yaml ./stocktake-config.yaml
On Kubernetes it is the same through kubectl exec and kubectl cp.
Don't copy stocktake.db itself while the app runs: its newest changes can
still be in stocktake.db-wal beside it. A snapshot of the whole volume, such
as a ZFS snapshot, is fine, because it takes both at the same moment.
To restore, stop the app, then:
- Put the copy back as
/data/stocktake.db. - Delete any
stocktake.db-walandstocktake.db-shmbeside it. They belong to the database you replaced, and SQLite would apply them to the one you restored. - Put
/data/formats/and the configuration back, and start the app.
Postgres¶
pg_dump --format=custom --file=stocktake.dump stocktake
Restore into an empty database with the app stopped
(pg_restore --no-owner --dbname=stocktake stocktake.dump), then start it: it
migrates the database to its own version. /data/formats/ and the
configuration are still files, backed up as above.
Test a restore. An untested backup is a hope.
Upgrading¶
The app migrates its own database at startup.
Back up first. Migrations roll forward; rolling an image back after one has run is not a supported path.
Config and app version are a matched pair. The app rejects unknown settings outright — a typo should be loud, not silently ignored — which also means a config written for a newer version is refused by an older one, and vice versa. Move them together, in one apply.
Installed the Helm chart from a clone of this repository before 0.68.0? It
ran 0.64.0 however many times you upgraded, unless you set image.tag: the
chart picked its image from a version number nobody had updated. Back up, then
move to the published chart with the values you installed with:
helm upgrade stocktake oci://ghcr.io/jarrodruggiero/charts/stocktake \
--namespace stocktake -f your-values.yaml
The release and its data volume carry over; the image moves to the newest
release, which migrates the database as it starts. The chart in the repository
now refuses to install without image.tag instead of guessing.
Reverse proxies¶
Nothing here terminates TLS, and the app will not pretend otherwise. Put Caddy,
Traefik or nginx in front, then set cookie_secure and trusted_proxies as
above.
If you terminate TLS at a tunnel service, note that it sees your traffic in clear at its edge. That is fine for many people and worth knowing for an app that shows everything you own.
Passkeys and security keys additionally require HTTPS — a browser rule, not ours. Internal-only HTTPS is fine: a real domain resolved internally with DNS-01 certificates satisfies it with no inbound exposure.
Monitoring and alerts¶
The app never alerts you about anything. It has no notification system, sends no
mail, and talks to no push service. The Helm chart installs no monitoring
either — no Prometheus, no exporters, not even a ServiceMonitor — because a
portfolio tracker should not decide what your cluster's observability looks
like.
What it does have is a /metrics endpoint, off by default:
metrics:
enabled: true
With the Helm chart, that goes under extraConfig.
It publishes the app's own health — when prices were last fetched, whether the last fetch worked, whether the database answers — and deliberately nothing about your holdings, values or trades. It is unauthenticated, because a scraper has no cookie and nothing to log in with, which is exactly why it is opt-in.
Ready-made alerting rules, for plain Prometheus and for the Prometheus Operator,
live in deploy/monitoring/
with the reasoning behind every threshold. The one worth reading even if you
use something else entirely: an unauthenticated request to this app must be
refused, so the healthy status code for a probe of / is 401 and a 200
means the login gate has broken open. Probing it with an ordinary "expect 2xx"
check gets you an alert that fires while everything is fine and stays silent
when it is not.