Skip to content

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: 1000 on 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 ./data on 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:

  1. Put the copy back as /data/stocktake.db.
  2. Delete any stocktake.db-wal and stocktake.db-shm beside it. They belong to the database you replaced, and SQLite would apply them to the one you restored.
  3. 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.