# Configuration reference Every setting, its default, and what it does. Configuration comes from three places, in this order of priority: 1. **Environment variables** — `APP_` prefix, `__` between levels. `APP_DATABASE__PASSWORD`, `APP_AUTH__COOKIE_SECURE=true`. 2. **The YAML file** at `APP_CONFIG_FILE`. 3. **Built-in defaults.** **Secrets belong in environment variables, never in the file.** The database password is read from `APP_DATABASE__PASSWORD` so it can come from a Docker secret or a Kubernetes Secret without ever being written to disk in the clear. !!! tip "The file is optional" Every setting has a default, so the app starts with no configuration file at all — and when it does, it boots into the [setup wizard](../getting-started/index.md), which writes one for you from the annotated default below. That is also why the wizard offers a database step only when nothing else has chosen one. If `APP_DATABASE__*` is set, or the file already has a `database:` block, the wizard reports what it found and moves on: the process is already connected to it, and offering to change it would be offering to lose a database. The password, port and SSL mode say how to reach a database, not which one, so set alone they leave the step offered; the wizard then tests and saves with that password, and keeps it out of the file. !!! warning "Unknown keys are rejected" A key the app does not recognise is a startup error, not a warning — a typo in a setting should be loud rather than silently ignored. It also means **the config file and the application version are a matched pair**: a file written for a newer version will be rejected by an older one, and vice versa. Move them together. ## The annotated file This is the shipped `config.yaml`. Every option appears, commented out at its default — uncomment what you want to change. ```yaml # ───────────────────────────────────────────────────────────────────────────── # Stocktake — configuration # # Every option the application understands is listed here. Options that are # commented out are showing their DEFAULT: uncomment one only to change it. # Anything left commented behaves exactly as written. # # Three places supply configuration, and they win in this order: # # 1. environment variables (highest — always wins) # 2. this file # 3. the built-in defaults (lowest) # # Environment variables use the prefix APP_ and a double underscore between # levels, so `auth.rate_limit.max_attempts` is APP_AUTH__RATE_LIMIT__MAX_ATTEMPTS. # That is how secrets reach the application without being written down: the # database password arrives as APP_DATABASE__PASSWORD and never appears here. # # NEVER put a secret in this file. # # Most of these can also be changed from Admin → Settings inside the # application, which writes back to this file and keeps these comments. If the # file is supplied read-only — a Kubernetes ConfigMap, a read-only volume, or # baked into an image — that page shows the values but cannot save them, and # says so. # # A mistyped KEY here stops the application at startup rather than being # ignored, which is deliberate: a silently ignored setting is the failure # nobody notices. (A mistyped environment variable is simply not seen — the # asymmetry is worth knowing.) # ───────────────────────────────────────────────────────────────────────────── # The application's name. Also decides the default database filename, so # changing it points the app at a different database. app_name: stocktake # How much detail reaches the log. DEBUG | INFO | WARNING | ERROR. # Takes effect at startup, so a change needs a restart. # log_level: INFO # Where this portfolio lives, as an IANA timezone name. # # EVERY date decision is made in this zone: whether a trade is in the future, # which financial year is current, where a performance window starts, what the # calendar highlights. Containers run in UTC, so leaving this wrong makes the # application a day behind for the first hours of each morning — and the new # financial year arrive a day late. Timestamps (session expiry, audit fields) # are stored in UTC regardless; only calendar decisions use this. timezone: Australia/Melbourne # ── Database ──────────────────────────────────────────────────────────────── # Only ever changed here — the settings page shows it read-only, because # editing it from a web form is a way to lose a database. Keep `type:` set: # a `database:` heading with nothing under it stops the app at startup. database: # sqlite (default) or postgres. # # SQLite needs nothing else running and is the right choice for one # household. Postgres is worth it if you already run one, for many people # writing at once, or for a database into the gigabytes. Either way the app # runs as one replica: the import preview, the statement mapper and the price # feed each live in its one process. type: sqlite # Where the SQLite file lives. MUST be on a mounted volume that survives a # restart — a container's own filesystem does not. Defaults to # /data/.db. path: /data/stocktake.db # For `type: postgres` instead of `path` (name and user are required, and # the password should come from APP_DATABASE__PASSWORD, never from here): # host: postgres # port: 5432 # name: stocktake # user: stocktake # sslmode: disable # ── Prices and exchange rates ─────────────────────────────────────────────── # Daily closes from Yahoo Finance. Only ticker SYMBOLS ever leave this machine # — never your holdings, quantities or balances. Runs once at startup when the # data is stale, then daily; also runnable by hand with # `python -m app.pricefeed`. price_feed: # Turn the feed off to freeze valuations at the last stored close. # Takes effect at startup, so a change needs a restart. # enabled: true # When the daily run happens, in the timezone above. Set it after the market # you follow has closed. hour: 18 minute: 30 # How far back to fetch when an instrument is first seen. Earlier means a # longer first run and a longer chart. Defaults to 2020-01-01. Exchange # rates also reach back to your first trade in each currency. # backfill_start: 2020-01-01 # ── Live prices ────────────────────────────────────────────────────────── # The daily run above records what each day CLOSED at. This shows what a # holding is worth right now, so the app is not stuck on yesterday all day. # # Live prices are stored beside the closes, flagged as provisional, and the # daily run replaces each with the real close once its market makes one — so # the history keeps only closing prices. # # One batched request covers every holding, and it runs only while somebody # is signed in AND an exchange you hold is trading. Nobody signed in, or # every market shut, and it makes no outbound requests at all. # quotes_enabled: true # quote_interval_minutes: 5 # The feed follows the top-level `timezone` unless you set this. Only worth # setting if the market you track keeps different hours from the zone you # count your days in. # timezone: Australia/Melbourne # ── Sign-in ───────────────────────────────────────────────────────────────── auth: # How long a signed-in session lasts. It slides: every request pushes the # expiry out again. # session_ttl_days: 30 # Two timeouts, and the shorter one wins. `session_idle_minutes` is time # since you last DID something — background polling deliberately does not # count, or a tab left open would keep itself alive forever. # `session_absolute_days` is a hard ceiling nothing can push out, which is # what bounds a stolen cookie. # session_idle_minutes: 60 # session_absolute_days: 7 # How long before the idle timeout the page starts warning, in seconds. # The overlay counts down and offers to stay signed in. # idle_warning_seconds: 120 # Send the session cookie only over HTTPS. Turn this ON once the application # is behind TLS. Leave it off on plain HTTP or nobody can sign in. # cookie_secure: false # The session cookie's name. Worth changing only if it collides with # something else on the same domain. # cookie_name: pf_session # Which peers may be believed when they send X-Forwarded-For or # X-Forwarded-Proto, as addresses or CIDR ranges. EMPTY BY DEFAULT, meaning # never: anyone can set those headers, so trusting them from an arbitrary peer # lets a caller choose the address recorded against their sign-ins, or claim # an encrypted connection that is not. Set this to your reverse proxy once # there is one in front: without it, a proxy that terminates TLS makes every # request look like plain HTTP, so the login page warns about it and passkeys # are not offered. Use the proxy's own address or range, never the network # your browsers are on, or their claims get believed as if a proxy had # written them. # trusted_proxies: [] # - 10.42.0.0/16 # Lockout after repeated failures, counted per account from every source # address together, so guesses spread across many machines still add up. # Anyone who knows an account's email can therefore keep it locked by # failing on purpose; passkeys and single sign-on are not affected. This covers # the two-factor code stage as well as the password, so a six-digit code # cannot be brute-forced by someone who already has the password. # rate_limit: # max_attempts: 8 # failures before the lockout starts # window_minutes: 15 # how long failures are remembered # lockout_minutes: 15 # how long the wait lasts # ── Passkeys and security keys ──────────────────────────────────────────── # Once set, a passkey appears as an option on the login page and can be # added from your account. It signs you in on its own: the ceremony requires # a fingerprint, face or PIN, so it stands in for the password AND the # authenticator code. # Two things must be true before WebAuthn can be switched on: # # 1. HTTPS. WebAuthn requires a secure context, so http://stocktake.home # cannot do it. That is a browser rule, not a limit of this app. # Public reachability is NOT required — an internal-only hostname with # DNS-01 certificates works perfectly well. # 2. A settled canonical domain. A credential binds permanently to the # rp_id it was registered under, so a passkey enrolled at # stocktake.example.com will not work from stocktake.home. Decide the # name BEFORE anyone enrols. # # rp_id is declared here and never read from the Host or X-Forwarded-Host # header: those are client-controlled, and rp_id is the anchor every # credential is bound to. # # If you terminate TLS at a Cloudflare tunnel, note that Cloudflare sees your # traffic in plaintext at its edge — fine for many people, worth knowing for # an app that shows everything you own. # ── Single sign-on (OIDC) ───────────────────────────────────────────────── # Hand sign-in to an identity provider you already run — Authentik, Authelia, # Keycloak, Pocket ID. Off by default, and inert when off: no request is made # to anybody until somebody presses the button. # # Local passwords keep working. That is deliberate: an IdP that is down # should be an inconvenience, not a lock-out from your own records. # # `provisioning` decides who may sign in: # off — only accounts already linked to this provider # invite — an unknown person may create an account, but only holding a # valid invite from a portfolio owner # open — anyone your provider authenticates gets an account # # Accounts are matched on the provider's `sub` claim, never on the email # address: an address is something a directory can often be told, and # matching on it would be a way to take over an existing account. # # redirect_uri must match what the provider has registered, exactly. It is # declared here rather than read from the request, for the same reason as # webauthn's rp_id — a value taken from a header is one the caller chose. # oidc: # enabled: false # issuer: https://auth.example.com/application/o/stocktake/ # client_id: stocktake # client_auth: basic # basic sends client_secret; none = public client # client_secret: from-your-provider # redirect_uri: https://stocktake.example.com/login/oidc/callback # button_label: Sign in with Authentik # provisioning: 'off' # scopes: [openid, email, profile] # webauthn: # enabled: false # rp_id: stocktake.example.com # rp_name: Stocktake # origins: # - https://stocktake.example.com # ── Housekeeping ──────────────────────────────────────────────────────────── # A daily sweep, on its own schedule rather than riding along with the price # feed — turning the feed off should not silently stop expiring sessions. # It removes expired sessions, forgets old failed sign-ins, and clears # half-finished imports left in the staging area. # maintenance: # enabled: true # hour: 3 # minute: 30 # attempt_retention_days: 30 # failed sign-ins are kept only to drive lockout # staged_upload_hours: 24 # a half-finished import holds real trade data # ── Metrics ───────────────────────────────────────────────────────────────── # A Prometheus endpoint at /metrics. OFF by default: every other route needs a # session, and a scraper has none, so this is the one anonymous endpoint in the # app and turning it on should be a decision. # # What it publishes is the application's own health — when prices were last # fetched, whether the last fetch worked, whether the database answers. It says # nothing about holdings, values or trades, and a test keeps the list closed so # it stays that way. # # Ready-made alert rules live in deploy/monitoring/. The application has no # alerting of its own and never sends mail. # metrics: # enabled: false # ── Broker imports ────────────────────────────────────────────────────────── imports: # Whether a broker CSV may create instruments it does not recognise. Off # means an unknown ticker stops the import so you can check it first. # allow_new_instruments: false # Largest upload accepted, in megabytes. A broker CSV or a statement PDF is # a few hundred kilobytes. This bounds what the application reads into # memory; limiting what a client can SEND is a job for the proxy in front. # max_upload_mb: 10 # Where the statement designer keeps the layouts you build, and its scratch # renderings. `templates_dir` holds work you want to keep, so it belongs on # a volume that survives a restart. `visual_dir` is scratch — page images # from a document you are mapping, deletable at any time, and it must NOT be # inside the data directory that gets backed up. So is `staging_dir`, where # a broker CSV's rows wait between the preview and the commit. # templates_dir: /data/formats # visual_dir: /scratch/visual # staging_dir: /scratch/staged # Reading statements that arrive as SCANS — a photograph of a page wrapped in # a PDF, with no text in it. On by default; where the tesseract program is # missing the app says so per upload and carries on. # # The work is entirely local. Tesseract is a binary in the container with no # network of its own, and there is no cloud OCR option here — a dividend # statement carries your name, your address and your holder number. # # Turn it off if you would rather no subprocess ran over an uploaded file. # You are still told why a scan produced nothing; that never depends on this. # ocr: # enabled: true # Broker CSV formats are FILES now, in app/formats/brokers/ — they ship with # the app, are tested in CI, and a new one is a pull request rather than a # block in one person's config. The interface can install one too. # # This key still exists to OVERRIDE a shipped format by name, for the case # where your broker's export differs from the one that was verified. Anything # here wins over the file, so do not paste a copy of a shipped format in: # that pins it forever and a later fix to the file would never reach you. # # brokers: # selfwealth: # kind: mapped # or commsec_transactions # exchange: ASX # currency: AUD # date_format: "%d/%m/%Y" # columns: # the fields the importer needs -> your headings # date: Trade Date # action: Buy/Sell # ticker: Code # units: Units # price: Price # brokerage: Brokerage ``` ## Optional features Whole sections of the application, switchable. Turning one off removes its navigation, its pages and its API routes — not just the link — so an install that does not want a feature is not carrying a door to it. ```yaml features: # The DCA calendar: a rotation of what to buy next, on an interval, for an # amount. Off means /schedule is gone and nothing prompts about it. # dca_schedule: true ```