Project

General

Profile

Actions

PF » History » Revision 2

« Previous | Revision 2/17 (diff) | Next »
Redmine Admin, 10/31/2026 10:15 AM
deploy-publish.sh


Version 1.3 — 2026-10-29 · companion of the host cheat sheet (conventions: Conventions, NPM, Operations, Git_Workflow)

Host dmz-web (Rocky Linux 10.2). Everything runs rootless under websvcmaster (no sudo) as Quadlet units. Design rule: shared-nothing — own networks, database, app container and volumes; the only shared piece is Nginx Proxy Manager (NPM), which joins the front network.

Item Value
Public name pf.sibernet.com.tr (DNS A record → FortiGate WAN IP; Let's Encrypt HTTP-01)
App site-pf — FastAPI/uvicorn on :8000 (one worker), image localhost/site-pf:<tag> built locally from ~/apps/pf; web GUI and API with own login (password + TOTP/QR, roles User and Admin), no public registration; app version 1.1.1
Database db-pf — TimescaleDB on PostgreSQL 16, docker.io/timescale/timescaledb:2.31.0-pg16
Networks net-pf (NPM + app, outbound allowed — the app fetches market data) and net-pf-db (app + db only, Internal=true: the database has no route to the internet and NPM cannot reach it)
Volume db-pf-data (PostgreSQL data)
Secrets pf-db-password, pf-app-secret (both openssl rand -hex 32, created without a trailing newline)
Installer pf-install.sh in ~/deploy (this file's procedures are what it does)
Source pf-app-<ver>.tar.gz (delivered with the release, not part of dmz-web-deploy); lives afterwards in its own repo

Naming follows the host rule: containers db-pf / site-pf, secrets pf-…. The unit description Description=Portfolio -- <domain> is what check-sites.sh reads, so ./check-sites.sh | grep pf works.


1. Install — pf-install.sh (websvcmaster)

First time, from ~/deploy (shell setup: host cheat sheet §0), with the source tarball next to the script:

./pf-install.sh --app-src=pf-app-1.1.1.tar.gz --build --access-list=admin-segments
  • --app-src=FILE unpacks the application (Containerfile, pinned requirements.txt, app/, tests/) into ~/apps/pf; existing files are never overwritten, and an archive with absolute or ../ paths is refused. It is for the first install — later code comes from the app's own repo (§2).
  • --build runs podman build -t localhost/site-pf:1.1.1 ~/apps/pf (the default tag; --app-image=localhost/site-pf:<tag> for another).
  • --access-list=NAME puts that NPM Access List on the new Proxy Host. The app has its own GUI login (§5), so this is a second layer, not the only one — still recommended while the host is new.
  • Other options: --db-image, --app-dir, --timezone, --skip-npm-api, --force; ./pf-install.sh -h.

What it does, in order: generates the Quadlet files into ~/deploy/quadlet/ (net-pf.network, net-pf-db.network, db-pf.volume, db-pf.container, site-pf.container), creates the two secrets, adds net-pf to nginx-proxy-manager.container, installs the units, starts nets → db (waits until healthy) → app (waits for /health), restarts NPM (every site is down ~10–20 s — run it off-hours) and creates the Let's Encrypt certificate and Proxy Host through NPM's API.

Before it changes anything it compares the Network= lines of the live nginx-proxy-manager.container with the repo copy and stops if the live file knows a network the repo copy lacks (copying it over would cut that site off — the 2026-10-25 outage). If it stops: add the missing lines to the repo copy, commit, run again.

Prerequisite: the DNS name must already resolve to the FortiGate WAN IP, otherwise the certificate request fails (the rest still works; add cert and host in the NPM UI later: Forward Hostname site-pf, Forward Port 8000, Force SSL, HTTP/2, Websockets, Block Common Exploits).

Then commit the generated units (root): deploy-commit.sh -m "pf: portfolio stack on pf.sibernet.com.tr".

Open the site and create the administrator on the first-run page — §5. Verify:

curl -sI --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444 | head -1     # HTTP/2 200
curl -s  --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444/health        # {"status":"healthy"}
podman exec site-pf curl -s http://127.0.0.1:8000/health/db                                       # {"status":"db ok"}
./check-sites.sh | grep pf
podman network inspect net-pf net-pf-db | jq '.[].containers | keys'   # net-pf: site, nginx-proxy-manager · net-pf-db: db, site

2. Application code and images

The no-host-packages rule holds: no Python, pip or libraries on Rocky; everything lives in the image. The Containerfile is python:3.12-slim-bookworm + curl (for the healthcheck) + pip install --only-binary=:all: from a fully pinned requirements.txt (66 wheels) — no compiler in the image, and a dependency without a wheel fails the build loudly instead of compiling something unreviewed.

Source layout (~/apps/pf):

File Job
app/config.py PF_* settings; DB URL built with quote_plus, so a password with / + = @ : cannot break it
app/database.py, app/models.py SQLAlchemy 2 (async, asyncpg); tables are created at start; price_history becomes a TimescaleDB hypertable when the extension is there
app/auth.py bcrypt (SHA-256 pre-hash), TOTP with replay protection, JWT, per-IP rate limit, per-account lockout
app/services/market_data.py Yahoo Finance (stocks, crypto, FX, dividends) and TEFAS (funds); raw, not dividend-adjusted prices
app/services/drip.py dividend reinvestment (§5)
app/services/portfolio.py, indicators.py positions, allocation, rebalancing hints, risk; RSI/MACD/SMA/EMA/Bollinger/ATR (pandas-ta)
app/services/mailer.py optional SMTP sender (notices, recovery links); silent when PF_SMTP_HOST is empty
app/main.py, app/schemas.py, app/cli.py API, validation, admin commands
app/static/ the web GUI: index.html, style.css, app.js (no inline script or style, so the CSP stays strict; charts are drawn as SVG without libraries)
tests/ 38 tests (pytest), see "Tests" below
  • Pinning: >= ranges drift (on 2026-10-29 an unpinned install resolves to pandas 3.x and a beta of pandas-ta). The list was resolved for Python 3.12 and import-tested; it includes greenlet, which SQLAlchemy's async layer needs and which is easy to miss. Change versions on purpose, build a new tag, test, then switch the unit. Not used on purpose: python-jose and passlib (unmaintained; passlib breaks with current bcrypt) — the app uses PyJWT and bcrypt directly.
  • Image tags: one tag per release (site-pf:1.1.0, 1.1.1, …), never latest — the previous tag is the rollback.
  • Config (PF_*, set in site-pf.container): PF_BASE_CURRENCY (default TRY; the default display currency of new users), PF_CURRENCIES (default TRY,USD,EUR,GBP: the choices in the currency switch; any other 3-letter code with a Yahoo FX rate also works, e.g. via PUT /auth/profile), PF_DIVIDEND_WITHHOLDING (fraction taken at source, default 0 — set it to what your broker really withholds, e.g. 0.10), PF_DRIP_DEFAULT (default true), PF_DIVIDEND_PAY_LAG_DAYS (default 0), PF_SYNC_ENABLED / PF_SYNC_INTERVAL_HOURS (daily), PF_REQUIRE_2FA (default true: end users must enrol 2FA, administrators may; false: optional for everybody — an administrator can still require it per account), PF_PW_LOCK_HOURS (24), PF_RECOVERY_TOKEN_MINUTES (30), PF_SMTP_HOST / PORT (587) / USER / PASSWORD / FROM / STARTTLS, PF_PUBLIC_URL (links in e-mails; default https://<PF_DOMAIN>), PF_LOGIN_RATE_LIMIT (default 5/minute), PF_ACCESS_TOKEN_MINUTES (30), PF_ENABLE_DOCS (0: hides /docs, /redoc, /openapi.json; 1 only while developing, never behind the public Proxy Host). The app refuses to start when PF_SECRET_KEY (secret pf-app-secret) is shorter than 32 characters.
  • Source code is not in dmz-web-deploy. Keep ~/apps/pf in its own repository (e.g. a bare repo /srv/git/pf-app.git, same procedure as host cheat sheet §3 Git A–E) and tag it with the same number as the image.
  • Tables: create_all at start creates what is missing. Later columns are added by a short list of idempotent ALTER TABLE … ADD COLUMN IF NOT EXISTS statements in app/database.py that runs at every start (release 1.1.1 adds seven columns to users: must_change_password, require_2fa, display_currency, is_owner, email, self_service_recovery, pw_locked_until, and makes the oldest administrator the Owner). Nothing is dropped or rewritten; take a pg_dump (3.C) before the first start of a new version anyway.
  • Tests: they need a throw-away PostgreSQL, never the production database (PF_ENV=test PF_TIMESCALE=off PF_SECRET_KEY=<32+ chars> PF_DATABASE_URL_OVERRIDE=postgresql+asyncpg://user:pw@host/pf_test pytest, with requirements-dev.txt installed in a venv away from the host's Python — e.g. on a workstation). Market data is faked in the tests: they prove the logic, not Yahoo/TEFAS behaviour.

3. Operations

A. New application version

The source is delivered as pf-app-<ver>.tar.gz. The old source stays aside as the rollback:

# 0. backup first: 3.C (pg_dump -Fc), copy it off the host
cd ~ && mv apps/pf apps/pf-1.1.0                        # keep the old tree
mkdir apps/pf && tar -xzf ~/deploy/pf-app-1.1.1.tar.gz -C apps/pf --no-same-owner --no-same-permissions
podman build -t localhost/site-pf:1.1.1 ~/apps/pf
# repo copy: change  Image=localhost/site-pf:1.1.1  in ~/deploy/quadlet/site-pf.container (keep HealthStartPeriod=90s)
# root:      deploy-commit.sh -m "pf: app 1.1.1"
cp ~/deploy/quadlet/site-pf.container ~/.config/containers/systemd/
systemctl --user daemon-reload && systemctl --user restart site-pf.service     # only site-pf, not db-pf and not NPM
podman logs site-pf | tail -20
~/deploy/check-sites.sh | grep pf

Rollback: put the previous tag back in the unit (same four lines). The added columns are harmless to the old version.

B. Logs and shell

journalctl --user -u site-pf.service -f
podman logs -f site-pf          podman logs -f db-pf
podman exec -it db-pf psql -U pf_user -d pf_db

C. Backup and restore — TimescaleDB is not plain PostgreSQL

A plain dump restored the plain way breaks hypertables. Use the custom format and restore with the Timescale pre/post calls:

# backup (websvcmaster) — then copy the file OFF the host; a dump next to the database is not a backup
podman exec db-pf pg_dump -U pf_user -Fc pf_db > ~/tmp/pf_db-$(date +%F).dump

# restore into an EMPTY database (same TimescaleDB version or newer)
podman exec db-pf psql -U pf_user -d postgres -c "create database pf_restore"
podman exec db-pf psql -U pf_user -d pf_restore -c "create extension if not exists timescaledb"
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_pre_restore()"
podman exec -i db-pf pg_restore -U pf_user -d pf_restore --no-owner < ~/tmp/pf_db-2026-10-29.dump
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_post_restore()"

Do one restore test before trusting the backups, and again after each TimescaleDB upgrade. The application volume has nothing to back up (no uploads); the secrets are not needed for a restore (set a new password in the new environment).

D. Password rotation

POSTGRES_PASSWORD is read only when the data directory is first created. To rotate: podman exec db-pf psql -U pf_user -d pf_db -c "alter user pf_user password '<new>'", then podman secret rm pf-db-password, create it again with the new value (printf '%s' '<new>' | podman secret create pf-db-password -), restart db-pf.service and site-pf.service.

E. Upgrading the database image

Dump first (C). Patch/minor (2.31.0-pg16 → 2.31.1-pg16): change the tag in the unit, cp, daemon-reload, restart db-pf.service, then ALTER EXTENSION timescaledb UPDATE; in pf_db (the first command in a fresh session). A PostgreSQL major jump (pg16 → pg17) is a dump-and-restore into a new volume, not a tag change.


4. Troubleshooting

  • App restarts in a loop right after boot → it came up before the database accepted connections. The units wait for db-pf (Podman 5: Notify=healthy); check podman healthcheck run db-pf and podman logs db-pf.
  • /health is 200 but /health/db is 503 → the password in the secret is not the password the database was initialised with (secret recreated after first start): rotate as in 3.D, or drop the volume if the database is still empty.
  • NPM 502 for the pf host → NPM is not on net-pf (podman network inspect net-pf | jq '.[0].containers | keys'); check the Network=net-pf.network line in the live nginx-proxy-manager.container and that NPM was restarted afterwards. Other sites also 502 → host cheat sheet §7 "NPM says 502".
  • Certificate request fails → the DNS name does not point to the FortiGate WAN IP yet, or TCP 80 does not reach NPM; fix DNS, then request the certificate in the NPM UI.
  • App container exits at once, log says PF_SECRET_KEY ... shorter than 32 characters → the pf-app-secret secret is missing or empty; recreate it (openssl rand -hex 32 | tr -d '\n' | podman secret create pf-app-secret - after podman secret rm pf-app-secret) and restart site-pf.service. Existing login tokens become invalid (30 minutes anyway).
  • The first-run page does not appear → an administrator already exists (podman exec -it site-pf python -m app.cli list-users). To get the first-run page again on a system that has a user created by the CLI: python -m app.cli set-role NAME user for every administrator — the page appears when no administrator exists; the setup code is in podman logs site-pf | grep FIRST-RUN (a new one on every start, valid until used).
  • The chart for 1D / 3D shows few points → prices are daily closes; there is no intraday data. 1W and longer are the meaningful periods.
  • Login answers 429 → either the per-IP limit (5 attempts per minute, the answer comes back after a minute) or the account lock (5 wrong passwords or codes lock the account for 15 minutes): podman exec -it site-pf python -m app.cli unlock NAME.
  • Rate limit hits everybody at once → every request seems to come from one address (the proxy). Check that NPM passes X-Forwarded-For (it does by default) and that the app runs with --proxy-headers (it does in the Containerfile).
  • Authenticator code is always refused → phone clock drift (turn automatic time on), or the code was already used (each 30-second code works once). Lost phone: an administrator uses Reset 2FA (or Set temporary password with "also remove the old 2FA") in the GUI, or on the host python -m app.cli reset-2fa NAME; then the person enrols again.
  • A dividend is stuck in pending_price → no price for its pay date yet (pay date in the future, or the data source was down); the daily sync completes it. Force it: POST /api/v1/dividends/sync.
  • Build fails on a dependency → no wheel for Python 3.12 (the --only-binary guard); pick a version with a wheel, do not install a compiler into the image.

5. Using the application

A. Web GUI, roles and first run

Open https://pf.sibernet.com.tr. Everything the API can do is in the GUI (the API stays for a later mobile app).

First run. While no administrator exists the page asks for the administrator password. It also needs the setup code that the app prints to its log at start (so only someone with host access can claim the system):

podman logs site-pf | grep FIRST-RUN        # code is valid until used; a new one each start

The administrator created there is the Owner (user name admin unless typed otherwise).

Roles.

User Administrator Owner
Portfolio, trades, dividends, charts, analysis yes no (403 on every data endpoint) no
Create / deactivate accounts, set temporary passwords, reset 2FA, require 2FA, set e-mail / recovery, unlock no yes, for users yes, also for administrators
Create other administrators no no yes
2FA mandatory (PF_REQUIRE_2FA=true); QR code at the first login optional (can be added later in My account; an enforcement per account is possible) like an administrator

Several users and several administrators are possible. The Owner cannot be deactivated. New accounts get a temporary password (the GUI can generate one and shows it once); the person must choose their own at the first login. Password rules: at least 12 characters, upper and lower case, a digit.

2FA login. At the first login a user who needs 2FA gets a restricted session that can only enrol: the page shows a QR code (and the secret for manual entry); after the 6-digit code is confirmed the session continues. Later logins ask for the code (each code works once; ±30 s clock tolerance).

Lost password / lost authenticator.

Case What happens
Lost password Administrator: Users → Reset a password (temporary password, forced change at the next login).
Lost authenticator (password known) Administrator: Reset 2FA; the person enrols again at the next login. If the account has an e-mail address, a notice is sent and the password cannot be changed by the person for 24 hours (PF_PW_LOCK_HOURS).
Lost both Administrator: Reset a password with "also remove the old 2FA" — one step; the forced password change at the next login stays allowed, enrolment is repeated.
Self-service Only if an administrator gave the account an e-mail address and ticked allow e-mail recovery (Users → E-mail / recovery…), and SMTP is configured. Login page: Forgot password / Lost authenticator → an e-mail with a single-use link (30 min; it dies with any later password or 2FA change; the link travels in the URL fragment, not in server logs). Lost authenticator also asks for the current password, then removes 2FA, sends a notice and starts the 24 h password lock; Forgot password is refused while that lock is on. The reply to a request is the same for every account, so nobody can probe which users exist.
Nothing configured Without PF_SMTP_HOST no mail is sent, the login page shows no recovery links, and the administrator path above is the only one.

On the host (break-glass, no GUI): podman exec -it site-pf python -m app.cli list-users | set-password NAME | unlock NAME | reset-2fa NAME | set-role NAME user|admin | create-user NAME [--admin]. The password is typed at a hidden prompt, never given as an argument.

Data isolation. Every query filters on the logged-in user's id; another user's transactions, dividends and settings answer 404, not 403. Instruments (/assets) are a shared catalogue; ownership exists only through transactions.

B-0. Pages (user)

  • Portfolio — Total wealth in the display currency chosen at the top right of the card (TRY, USD, EUR, GBP, or any code with a Yahoo rate; saved per user), the change over the chosen period and a chart: 1D, 3D, 1W, 1M, 3M, 6M, YTD, 1Y, All. The line is green when the portfolio gained over the period and red when it lost; "gain" excludes money you paid in or took out during the period (a deposit is not a win). The dashed line is the net money paid in. Prices are daily closes, so 1D and 3D have few points. Below: totals, positions, Holdings: market analysis and suggestion, asset mix versus the risk profile, weight by holding, risk.
  • Holdings analysis — one row per holding with weight, unrealized P/L, RSI, trend and a suggestion — BUY, ACCUMULATE, HOLD, REDUCE, SELL — and the reasons. It is a score from six rules (RSI over 70 / under 30, MACD against its signal line, price against the 50-day average, 50-day against 200-day average, Bollinger bands): +3 or more BUY, +2 ACCUMULATE, −2 REDUCE, −3 or less SELL, otherwise HOLD. Technical signals only — they know nothing about fundamentals, taxes, your goals or your other assets. Information, not investment advice.
  • Trades, Dividends (reinvestment switch per holding, sync, chart), Market (price chart and indicators per instrument), Settings (risk profile, reinvestment default, 2FA, password).
  • Administrators see Users and My account only.

B. Dividend reinvestment (DRIP)

Rule Behaviour
Switch per holding (PUT /api/v1/holdings/{asset_id}/reinvest), else the user default (PUT /api/v1/auth/profile drip_default), else PF_DRIP_DEFAULT (on)
Entitlement shares held at the close before the ex-date; a purchase on the ex-date earns nothing
Amount shares × per-share × (1 − withholding)
Reinvest on a BUY (source: drip, fee 0) is booked at the first close on/after the pay date; quantity = net ÷ price, rounded down to 6 decimals; the rest is kept as cash_residual
Reinvest off the net amount is recorded as cash income (status: paid)
No price yet status: pending_price; the daily sync completes it (the later dividends of that asset wait, so the share count stays right)
Compounding reinvested shares are normal shares and earn the next dividend
Idempotent one row per user, asset and ex-date — syncing twice does nothing
Manual entry POST /api/v1/dividends for companies the feed does not know; optional withholding, reinvest, reinvest_price
Corrections adding or deleting a manual trade recomputes that asset's dividends; PUT .../reinvest?apply_to_history=true and POST /api/v1/dividends/rebuild/{asset_id} do it on demand. DRIP purchases cannot be deleted by hand (409)
Returns reinvested dividends raise the cost basis but are not new money: money_invested counts only your own purchases, total_return = market value + sale proceeds + cash dividends − money invested

Limits to know: Yahoo reports ex-dates only, so the pay date is ex-date + PF_DIVIDEND_PAY_LAG_DAYS (0); the dividend is assumed to be in the asset's own currency; TEFAS funds and crypto have no dividend events; withholding is one global rate unless you pass it per manual entry.

C. Endpoints (/api/v1, bearer token except login)

Area Calls
Setup GET setup/status, POST setup/admin (first run, needs the setup code)
Auth POST auth/login, auth/2fa/setup (QR as data URI), auth/2fa/confirm, auth/2fa/disable, auth/password, auth/recover/request, auth/recover/confirm; GET auth/me; PUT auth/profile (risk_profile: conservative / balanced / growth, drip_default, display_currency)
Admin (administrators only) GET/POST admin/users, POST admin/users/{id}/reset-password (remove_2fa), reset-2fa, require-2fa, recovery (e-mail, self-service), unlock, active
Instruments POST assets (stock/crypto = Yahoo symbol such as THYAO.IS, AAPL, BTC-USD; fund = TEFAS code; looked up on creation, ?verify=false skips it), GET assets
Trades POST transactions (buy/sell, quantity, price, fee, trade_date; selling more than held is refused), GET transactions (asset_id, source, paging), DELETE transactions/{id}
Dividends GET dividends, POST dividends, POST dividends/sync (6 per hour), POST dividends/rebuild/{asset_id}, GET/PUT holdings/{asset_id}/reinvest
Portfolio GET portfolio (positions, totals in the user's display currency, FX conversion), currencies, portfolio/history?period=1d|3d|1w|1m|3m|6m|ytd|1y|all (points + summary: start/end value, money in, gain, gain %), portfolio/analysis, portfolio/allocation, portfolio/rebalance, portfolio/risk
Market GET market/{asset_id}/indicators, market/{asset_id}/history

portfolio/rebalance compares the asset-class mix (funds / stocks / crypto) with an illustrative target for the chosen risk profile (conservative 65/30/5, balanced 40/50/10, growth 20/65/15) and names the amount to move for any class more than 5 points off. Indicators and rebalancing are information, not personal investment advice.


Document history

Version Date Status Changes
1.3 2026-10-29 Latest App 1.1.1: web GUI (first-run administrator page with setup code, roles User / Administrator / Owner, QR enrolment, mandatory 2FA for users and optional for administrators, forced password change), total wealth with currency switch and period chart (green / red), holdings analysis with BUY / HOLD / SELL suggestions, admin-assignable e-mail recovery (optional SMTP), e-mail notice and 24 h password-change lock after 2FA removal, "reset password and remove 2FA" in one step. Seven added users columns (idempotent). pf-install.sh 1.2 (image tag 1.1.1, commented SMTP lines, first-run "Next" text). §5 rewritten; §3.A upgrade from the tarball; §4 entries; config variables; 38 tests.
1.2 2026-10-29 Superseded The real application replaces the starter: login with TOTP 2FA (rate limit, lockout, replay protection, no public registration), transactions, dividend reinvestment (DRIP), Yahoo/TEFAS market data on a TimescaleDB hypertable, positions and allocation in the base currency, indicators, risk statistics, class-level rebalancing hints, admin CLI. pf-install.sh 1.1: --app-src instead of --init-app, image tag 1.1.0, longer start period. New §5 (users and 2FA, DRIP rules, endpoint list); §2 file map, config variables, tests; §4 entries for secret key, 429, authenticator codes, pending_price. Pins: greenlet added (SQLAlchemy async), PyJWT/bcrypt instead of python-jose/passlib.
1.1 2026-10-29 Superseded Rewritten around pf-install.sh (one procedure instead of nine manual steps, with the live-vs-repo NPM Network= guard). Two networks (net-pf-db is Internal=true); DB healthcheck and Notify=healthy; explicit .volume unit; pinned images (2.31.0-pg16, site-pf:<tag>) and a fully pinned, wheels-only requirements.txt; secrets as hex (URL-safe) and the URL built with quote; /docs off; Access List option; Timescale-aware backup/restore, password rotation and DB upgrade; troubleshooting; history table. Fixes of the 1.0 draft: wrong diff expectation (+ vs >), NPM snippet repeating section headers, unpinned latest tags.
1.0 2026-10-29 Superseded Draft (manual nine-step procedure). Never committed.

Updated by Redmine Admin 6 days ago · 17 revisions