Home Deployment
Portfolixir is a local self-hosted application. For a small home setup, build the production release with Docker Compose and put your own reverse proxy in front of it. The Compose file publishes the application and the MCP companion on the host’s loopback interface only and never publishes the database. Inside their containers both listen on every interface, so in Compose the port mappings, not the application, keep them on the host’s loopback (“Reach” below).
Prerequisites
- Docker Engine 28.3.3 or newer, with Docker Compose. From 28.0 the engine
stops other machines on the network from reaching a port published on
127.0.0.1directly, and 28.3.3 closes the case in which a firewall reload reopened that path (CVE-2025-54388). On an older engine the loopback port mappings do not keep the instance on this machine; - a checkout of this repository;
- a
.envfile with the secrets (see below); - no real portfolio, bank, broker, wallet, or statement data in fixtures.
The first build pulls the pinned base images and downloads Debian packages, Hex
packages from hex.pm and npm packages from the npm registry. Behind a proxy, pass
it into the build with Docker’s predefined proxy build arguments (HTTP_PROXY,
HTTPS_PROXY, NO_PROXY; for example
docker compose build --build-arg HTTPS_PROXY=http://proxy.example:3128) or the
Docker client’s proxy configuration. Behind a proxy that intercepts TLS, pass
its CA to the build as the build secret build_ca: both Dockerfiles trust it
in the steps that download, the Debian packages included, and in no other
step. No layer keeps it, so neither the images they ship nor Docker’s build
cache carry it; a cache built with the secret by an earlier version may still
hold it in a build-stage layer, and docker builder prune clears that. Without
the secret, the builds are unchanged. Docker does not count a secret in its
build cache, so after changing or dropping the CA, build once with
--no-cache.
docker build --secret id=build_ca,src=/path/to/proxy-ca.crt -f Dockerfile.release .
docker build --secret id=build_ca,src=/path/to/proxy-ca.crt mcp-server
With Compose, name the secret in a docker-compose.override.yml beside
docker-compose.yml, which Compose reads by itself, and build as usual:
services:
app:
build:
secrets: [build_ca]
mcp:
build:
secrets: [build_ca]
secrets:
build_ca:
file: /path/to/proxy-ca.crt
Debian packages: a proxy on loopback, a blocked mirror
Dockerfile.release installs Debian packages in both of its stages before
anything else, through docker/apt-install.sh. With the build_ca secret, apt
fetches the sources that name deb.debian.org over HTTPS and verifies them
against that CA, read where Docker mounts it, so a proxy that tunnels only
HTTPS carries these downloads too. That CA is then all apt trusts, so
build_ca may be a bundle: for a proxy that only tunnels, the system’s CA
bundle; for one that re-signs some hosts and tunnels the rest, its CA and the
system’s bundle together, written outside the checkout so that it is never
committed and never copied into an image by the development Dockerfile’s
COPY . .:
cat proxy-ca.crt /etc/ssl/certs/ca-certificates.crt > $HOME/build-ca.pem.
Without the secret, apt fetches from http://deb.debian.org as it always has.
Three cases need more:
- A proxy on the host’s loopback. A build runs in a network of its own, so
a proxy listening on
127.0.0.1is out of its reach. Build with the host’s network,--network host(under Compose,network: hostin the service’sbuild:), and pass the proxy as a build argument. The host’s network then applies to the whole build, every step of it and, in the Compose example below, both services’, so code that runs at build time, such as dependency compilation, can reach every service on the host’s loopback, a running instance included during a rebuild: prefer a proxy the build’s own network can reach, or stop the instance before rebuilding. - A host whose egress blocks
deb.debian.org. Name the Debian mirrors it can reach in two build arguments, each a base URL such ashttp://mirror.example/. UnderDEBIAN_MIRRORthe mirror serves the Debian archive atdebian(the suitesbookwormandbookworm-updates); underDEBIAN_SECURITY_MIRROR, the security archive atdebian-security(bookworm-security). Many public mirrors carry nodebian-security, so the second names one that does; left empty, it isDEBIAN_MIRROR. Give the base, not the archive path:http://ftp.example.org/, nothttp://ftp.example.org/debian/, which the build refuses. Each mirror keeps the scheme you write, with the secret too, and apt checks every package against Debian’s archive keys whichever mirror serves it. Anhttps://mirror is verified againstbuild_ca, or without it against the stage’s own CA store; the runtime stage has none before it installsca-certificates, so there the build stops before apt with one line. Pass a CA bundle as the secret, or name the mirror withhttp://. A mirror URL is recorded in the image’s build history, so it must carry no credentials. The build refuses one with credentials in its authority (user:password@), but nothing stops a token elsewhere in it: never put a token anywhere in the URL, its path included. Empty, both arguments change nothing. - A host that reaches no Debian mirror at all. Build the application’s
image on a machine that does, and move it with
docker saveanddocker load. Portfolixir publishes no image: a release is a tag of this repository, never an installable artifact, so the image you move is your own build. Without a second machine, the README’s “Run from source” starts Portfolixir as a development server (MIX_ENV=dev) instead, not a release.
A build through a proxy on the host’s loopback, from mirrors:
docker build --network host --secret id=build_ca,src=$HOME/build-ca.pem \
--build-arg HTTPS_PROXY=http://127.0.0.1:3128 \
--build-arg DEBIAN_MIRROR=http://mirror.example/ \
--build-arg DEBIAN_SECURITY_MIRROR=http://security.example/ \
-f Dockerfile.release .
The same under Compose, in docker-compose.override.yml:
services:
app:
build:
network: host
secrets: [build_ca]
args:
HTTPS_PROXY: http://127.0.0.1:3128
DEBIAN_MIRROR: http://mirror.example/
DEBIAN_SECURITY_MIRROR: http://security.example/
mcp:
build:
network: host
secrets: [build_ca]
args:
HTTPS_PROXY: http://127.0.0.1:3128
secrets:
build_ca:
file: $HOME/build-ca.pem
The move: Compose runs the application from the image <project>-app,
portfolixir-app in the commands below; docker compose config --images, run
on the host, prints the name it expects. Build the image under that name on
the other machine, from a checkout of the same release and for the host’s
platform: --platform linux/amd64 or linux/arm64, whichever docker version
names under OS/Arch on the host, because an image for another platform does
not run there. Load it on the host, build only the companion there, which
installs no Debian package, and start the stack without a build. Repeat the
move for every release: on this route, the docker compose build --pull of
Upgrade does not apply.
# On the machine that reaches a mirror, for the host's platform:
docker build --platform linux/amd64 -t portfolixir-app -f Dockerfile.release .
docker save -o portfolixir-app.tar portfolixir-app
# On the host, with portfolixir-app.tar copied over:
docker load -i portfolixir-app.tar
docker compose build mcp
docker compose up -d --no-build
The development image (Dockerfile) keeps its plain apt-get from
http://deb.debian.org: --network host and the move reach it; build_ca,
DEBIAN_MIRROR and DEBIAN_SECURITY_MIRROR do not.
Secrets and settings
Create .env from .env.example, readable by you only, and keep it that way:
install -m 600 .env.example .env
The stack refuses to start while a secret is missing, the application and the
MCP companion refuse to start with a token shorter than 32 bytes or equal to a
placeholder, and the application refuses a SECRET_KEY_BASE shorter than 64
bytes, equal to a placeholder, or equal to a value committed in this
repository. Generate each token and SECRET_KEY_BASE
with openssl rand -base64 48, and POSTGRES_PASSWORD with
openssl rand -hex 32:
the Compose file splices it into the database URL, where a / or # from
base64 would break the connection string. Generate PORTFOLIXIR_UI_PASSWORD,
the web UI’s login, with openssl rand -base64 24, or choose a passphrase and
put it in single quotes in .env (PORTFOLIXIR_UI_PASSWORD='…'), because
Compose reads a $ in an unquoted value as a variable. Such a passphrase holds
no single quote, which ends the quoted value, and does not end in a backslash,
which escapes the closing quote; openssl rand -base64 24 prints neither and
needs no quotes. Left empty, the web UI is open.
| Variable | Required | What it does |
|---|---|---|
SECRET_KEY_BASE |
yes | Signs the session cookie; the signing salts are derived from it. |
POSTGRES_PASSWORD |
yes | The database password; the app builds its connection string from it. |
PORTFOLIXIR_API_TOKEN |
yes | The bearer token of the JSON API and of the MCP companion’s upstream calls. The Compose file hands it to the application as it is and names it mcp through PORTFOLIXIR_API_PRINCIPAL, so the audit journal names mcp for every write the companion makes. |
PORTFOLIXIR_API_PRINCIPAL |
no | The name the audit journal records for a write made with PORTFOLIXIR_API_TOKEN (1 to 32 characters of a-z, 0-9, _ and -). The Compose file sets it to mcp; leave it alone there. Unset, that token’s writes carry no name. A name PORTFOLIXIR_API_TOKENS also uses stops the application at boot. |
PORTFOLIXIR_API_TOKENS |
no | Further API tokens as name=token entries, comma-separated (scripts=<token>; a name is 1 to 32 characters of a-z, 0-9, _ and -; a token here cannot contain a comma, which openssl rand -base64 48 never prints; spaces around a name, a token and a comma are ignored). A write made with one is journaled under its name. Each token meets the same rules, and a name or a token may appear only once, PORTFOLIXIR_API_TOKEN included, or the application refuses to start naming the entry. Every token has the same full authority; the name attributes, it does not restrict. |
PORTFOLIXIR_MCP_TOKEN |
yes | The bearer token an MCP client presents to the companion. |
PORTFOLIXIR_UI_PASSWORD |
no | Set it to require a login on the web UI (ADR-0045); generate it with openssl rand -base64 24. Unset or empty, the UI is open — acceptable only behind reverse-proxy authentication. Changing it ends every login made with the old password. |
PORTFOLIXIR_SESSION_DAYS |
no | How many days a UI login stays valid (default 30). The window slides: using the instance renews it, so you are asked again only after a full period of not using it. 0 turns the server-side expiry off: the browser forgets the login when it closes, but a copy of the session cookie never expires, so prefer a number of days. A logout clears the login in that browser only, and a copy of the session cookie taken earlier stays valid; to end every login, change PORTFOLIXIR_UI_PASSWORD or rotate SECRET_KEY_BASE. |
PHX_HOST |
no | The name the reverse proxy serves (default localhost). Requests under any other Host are refused with 421. |
PORTFOLIXIR_ALLOWED_HOSTS |
no | Further names, comma-separated (a LAN address, a second proxy name). The Compose file adds app, the name the MCP companion reaches the application under. |
PHX_FORCE_SSL |
no | true redirects plain HTTP to HTTPS and sets HSTS. Set it once the reverse proxy terminates TLS and sends X-Forwarded-Proto from loopback or from an address named in PORTFOLIXIR_TRUSTED_PROXIES; off by default, because a loopback instance has no TLS to redirect to and the application never terminates TLS itself. localhost and 127.0.0.1 are never redirected: the container’s health check, and an MCP companion run outside Compose at its default base URL http://127.0.0.1:4000. |
PORTFOLIXIR_FORCE_SSL_EXCLUDED_HOSTS |
no | Host names, comma-separated, that PHX_FORCE_SSL leaves on plain HTTP: no redirect and no HSTS for a request under them. The Compose file sets it to app, the name the MCP companion calls the application under on the Compose network, where there is no TLS; leave it alone in Compose. Name only internal names here, never the one the reverse proxy serves. |
PORTFOLIXIR_TRUSTED_PROXIES |
no | Addresses or CIDR blocks, comma-separated, whose X-Forwarded-For (the login and token throttle’s source) and X-Forwarded-Proto (the scheme) the application believes. Empty, the throttle counts the connecting address, which behind a proxy is the proxy, and only a proxy on loopback can mark a request as HTTPS. |
PORTFOLIXIR_MCP_ALLOWED_HOSTS |
no | Further Host names the MCP companion answers under, comma-separated, each also admitted as a browser origin: a proxy name, 127.0.0.1:<port> when the mapping publishes the companion on another host port than 4001, or a browser-based MCP client’s host:port. |
PORTFOLIXIR_MCP_READ_ONLY |
no | true makes the MCP companion read-only: it lists and calls only the tools that change nothing (off by default; any value other than true, false, 1, 0 or empty stops it with the variable named). It narrows the companion, not PORTFOLIXIR_API_TOKEN, which can still write through the API. It is the same as PORTFOLIXIR_MCP_PROFILE=read. |
PORTFOLIXIR_MCP_PROFILE |
no | The MCP companion’s tool profile: read (only the tools that change nothing), book (the reads, the creates and the replace-shaped writes, which the same write sent the former value undoes, except that a rename back keeps the in-between name as a former name and an upsert over a provider date stays manual until the admin quote release; no removal, merge, ISIN change or rule retirement) or full (every tool). Empty is full, or read when PORTFOLIXIR_MCP_READ_ONLY=true; beside that switch, book or full stops the companion naming both variables, as does any other value. It narrows the companion, not PORTFOLIXIR_API_TOKEN. See API and MCP. |
TZ |
no | The zone that decides what “today” is, as a tz name (Europe/Berlin); empty is UTC. Every date check — a statement or a quote not in the future, a rule version not backdated — reads the application’s calendar day in this zone, and each database session takes the same zone when it connects, so the database’s own date checks agree with it; the database server’s timezone setting then does not matter. A value the database does not know as a zone name is logged once at startup and the session keeps the server’s zone. |
PORTFOLIXIR_BACKGROUND_FETCH |
no | off (or 0, false, no) leaves logo discovery and the scheduled quote and FX downloads off from boot, so the instance calls out only when someone asks it to. Unset or anything else keeps them on; it can only turn fetching off. |
PORTFOLIXIR_LOGO_DIR |
no | The absolute directory stored logos are kept in. The release image sets it to /var/lib/portfolixir/logos, the portfolixir-logos volume, because the release itself is read-only for the user it runs as; leave it alone in Compose. |
Without a UI password and with the port opened beyond loopback, the application logs a warning at startup naming this table; with a UI password shorter than 12 characters it warns as well, and starts either way.
A server run from source (the README’s “Run from source”, mix phx.server
under MIX_ENV=dev) reads no .env, no POSTGRES_PASSWORD and no
DATABASE_URL: it takes its port and its database from the shell’s
environment, under the names config/dev.exs reads.
| Variable | Default | What it does |
|---|---|---|
PORT |
4000 |
The port the server listens on. |
DATABASE_NAME |
portfolixir_dev |
The database mix ecto.setup creates and migrates, and the server uses. |
DATABASE_HOST |
127.0.0.1 |
The PostgreSQL server’s host. |
DATABASE_PORT |
5432 |
The PostgreSQL server’s port. |
It connects as user postgres with password postgres, which
config/dev.exs fixes: no variable changes them.
Reach
A release started on its own listens on loopback unless PHX_BIND_ALL says
otherwise. In the Compose deployment the application listens on every
interface inside its container, because a port mapping forwards to the
container’s network interface, never to its loopback, and the port mapping,
not the application, keeps it on the host’s loopback. What reaches it there is
this host, through the loopback mapping and through the container’s own
address, and the other containers of the stack; nothing else on the network,
on Docker Engine 28.3.3 or newer. The application cannot tell this apart from
a port opened to the network, so without a UI password the startup warning
appears in every Compose install. Set
PORTFOLIXIR_UI_PASSWORD for a Compose install: it also locks the web UI
against the other containers and against whatever else runs on this host.
Outbound, the application fetches logos and follows provider redirects only to
public addresses (SECURITY.md). On its own schedule the release also fetches
the ECB’s euro reference rates
(https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml), five seconds
after it starts and then every 12 hours, and the quote history of every
security that has a quote provider, every 6 hours. Once, after that first
rate sync or after an import, when a booking in a non-EUR currency (its
own, its cash account’s or its security’s) predates that currency’s earliest
stored rate, it also fetches the ECB’s historical
series (https://www.ecb.europa.eu/stats/eurofxref/eurofxref-hist.xml,
issue #1120). Both schedules and logo discovery are on in a release
(config/prod.exs). PORTFOLIXIR_BACKGROUND_FETCH=off leaves all three off
from boot, the historical series included: the quotes and rates already
stored stay as they are, and every figure is computed from them. The switch
stops what the instance does by itself, not what someone asks of it. A quote
or rate sync started on a screen or by the agent, a logo update and the
security search still reach their providers, so a host that must not reach
them at all still needs its egress blocked. On an IPv6-only host behind
DNS64, use the
well-known NAT64 prefix 64:ff9b::/96: an address in it is judged by the IPv4
address it carries. The local-use translation prefix 64:ff9b:1::/48 is a
special-purpose block like the private ranges, so every address a DNS64 builds
in it is refused, and logo downloads and redirected provider requests fail.
Database roles (recommended for a new install)
As shipped, the application connects as the database’s bootstrap superuser, the
role the db image creates from POSTGRES_USER, which also owns every table.
The append-only and audit-journal triggers then bind the application’s code,
not its credential: a superuser or a table’s owner can switch a trigger off or
drop it. The recommended setup gives the database three roles, each with one
job:
- the bootstrap superuser, for administration only: backups, restores and the grants below;
portfolixir_owner, not a superuser, which owns the tables and runs the migrations;portfolixir_app, which the application connects as: it reads and writes rows, owns nothing and holds noTRUNCATE, so it can neither change a table nor switch off or drop a trigger.
This is for a new install, before its first start. An instance that already runs moves onto the roles by a backup and a restore as the owner: see “Moving an existing instance onto the roles” below.
- Add two passwords to
.env, each fromopenssl rand -hex 32:PORTFOLIXIR_OWNER_DB_PASSWORDandPORTFOLIXIR_APP_DB_PASSWORD. -
Start the database alone and create the roles. The passwords reach
psqlon its standard input, never on a command line:docker compose up -d db OWNER_PW=$(grep '^PORTFOLIXIR_OWNER_DB_PASSWORD=' .env | cut -d= -f2-) APP_PW=$(grep '^PORTFOLIXIR_APP_DB_PASSWORD=' .env | cut -d= -f2-) docker compose exec -T db psql -v ON_ERROR_STOP=1 -U portfolixir -d portfolixir_prod <<SQL CREATE ROLE portfolixir_owner LOGIN PASSWORD '$OWNER_PW'; CREATE ROLE portfolixir_app LOGIN PASSWORD '$APP_PW'; ALTER DATABASE portfolixir_prod OWNER TO portfolixir_owner; REVOKE ALL ON DATABASE portfolixir_prod FROM PUBLIC; GRANT CONNECT ON DATABASE portfolixir_prod TO portfolixir_app; REVOKE CREATE ON SCHEMA public FROM PUBLIC; GRANT USAGE ON SCHEMA public TO portfolixir_app; ALTER DEFAULT PRIVILEGES FOR ROLE portfolixir_owner IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO portfolixir_app; ALTER DEFAULT PRIVILEGES FOR ROLE portfolixir_owner IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO portfolixir_app; SQLThe two default-privilege lines reach every table a migration adds, now or later: each is granted to the runtime role as the owner creates it, and
TRUNCATEnever is. -
Create
docker-compose.override.ymlbesidedocker-compose.yml; Compose reads it with every command. It connects the application as the runtime role, starts the release without the migration step, which the runtime role cannot run, and adds a one-offmigrateservice that runs the migrations as the owner:services: app: entrypoint: ["/opt/app/bin/portfolixir"] command: ["start"] environment: DATABASE_URL: postgres://portfolixir_app:${PORTFOLIXIR_APP_DB_PASSWORD:?set it in .env}@db:5432/portfolixir_prod migrate: build: context: . dockerfile: Dockerfile.release profiles: ["migrate"] depends_on: db: condition: service_healthy entrypoint: ["/opt/app/bin/portfolixir", "eval", "Portfolixir.Release.migrate()"] environment: DATABASE_URL: postgres://portfolixir_owner:${PORTFOLIXIR_OWNER_DB_PASSWORD:?set it in .env}@db:5432/portfolixir_prod SECRET_KEY_BASE: ${SECRET_KEY_BASE:?set SECRET_KEY_BASE in .env} PORTFOLIXIR_API_TOKEN: ${PORTFOLIXIR_API_TOKEN:?set PORTFOLIXIR_API_TOKEN in .env} PHX_HOST: ${PHX_HOST:-localhost} -
Migrate as the owner, then start:
docker compose run --rm --build migrate docker compose up --build -d
With these roles, only the runtime role writes while the application runs. A
delta read’s as_of (since=) is bounded by the transactions in flight that
the application’s own role can see, so a write under another role while the
application serves reads could slip past a poll; the owner therefore migrates
only while the application is stopped, and the bootstrap superuser only backs
up and restores. Two procedures below change. An upgrade stops the application
and the companion (docker compose stop app mcp) and runs
docker compose run --rm --build migrate after the build and before
docker compose up -d, because the application no longer migrates on start. A
restore gives the new database back to the owner after its step 2, restores as
the owner in step 3 by adding --role=portfolixir_owner to pg_restore, so
the tables keep their owner and their grants, and migrates as in the upgrade
before step 5:
docker compose exec -T db psql -v ON_ERROR_STOP=1 -U portfolixir -d portfolixir_prod <<'SQL'
ALTER DATABASE portfolixir_prod OWNER TO portfolixir_owner;
REVOKE ALL ON DATABASE portfolixir_prod FROM PUBLIC;
GRANT CONNECT ON DATABASE portfolixir_prod TO portfolixir_app;
SQL
The recipe was checked with PostgreSQL’s own tools and the release outside
Compose: the runtime role writes journaled records and is refused TRUNCATE,
switching a trigger off, dropping one and creating a table; a backup restored
as the owner keeps every trigger and grant.
Moving an existing instance onto the roles
An instance that already runs as the bootstrap superuser moves onto the two roles by a backup and a restore as the owner. Compose creates roles only on an empty database volume, and a restore as the owner is what hands every table, sequence and trigger to it; the records themselves do not change. The instance is stopped from step 2 until step 6 starts it.
- Add the two passwords to
.env, each fromopenssl rand -hex 32:PORTFOLIXIR_OWNER_DB_PASSWORDandPORTFOLIXIR_APP_DB_PASSWORD. -
While the instance still runs, note the three figures “Check the restore” below compares: the Wealth page and the API read them only from a running application. Then stop the application and the companion, back up the database, which keeps running, and note its trigger count:
docker compose stop app mcp umask 077 mkdir -p ~/portfolixir-backups docker compose exec -T db \ pg_dump -U portfolixir -d portfolixir_prod --format=custom \ > ~/portfolixir-backups/portfolixir-before-roles.dump docker compose exec -T db \ pg_restore --list < ~/portfolixir-backups/portfolixir-before-roles.dump \ > /dev/null && echo "backup reads" docker compose exec -T db psql -U portfolixir -d portfolixir_prod -tAc \ "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal"Go on only after it printed
backup reads: step 4 removes the database, and the backup is then its only copy. -
Create the two roles. The passwords reach
psqlon its standard input, never on a command line:OWNER_PW=$(grep '^PORTFOLIXIR_OWNER_DB_PASSWORD=' .env | cut -d= -f2-) APP_PW=$(grep '^PORTFOLIXIR_APP_DB_PASSWORD=' .env | cut -d= -f2-) docker compose exec -T db psql -v ON_ERROR_STOP=1 -U portfolixir -d portfolixir_prod <<SQL CREATE ROLE portfolixir_owner LOGIN PASSWORD '$OWNER_PW'; CREATE ROLE portfolixir_app LOGIN PASSWORD '$APP_PW'; SQL -
Replace the database with an empty one the owner owns, carrying the runtime role’s grants and the default privileges every table the restore creates receives:
docker compose exec -T db dropdb -U portfolixir portfolixir_prod docker compose exec -T db createdb -U portfolixir -O portfolixir_owner portfolixir_prod docker compose exec -T db psql -v ON_ERROR_STOP=1 -U portfolixir -d portfolixir_prod <<'SQL' REVOKE ALL ON DATABASE portfolixir_prod FROM PUBLIC; GRANT CONNECT ON DATABASE portfolixir_prod TO portfolixir_app; REVOKE CREATE ON SCHEMA public FROM PUBLIC; GRANT USAGE ON SCHEMA public TO portfolixir_app; ALTER DEFAULT PRIVILEGES FOR ROLE portfolixir_owner IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO portfolixir_app; ALTER DEFAULT PRIVILEGES FOR ROLE portfolixir_owner IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO portfolixir_app; SQL -
Restore the backup as the owner, in one transaction: every table and sequence it creates is the owner’s and, by the default privileges, the runtime role’s to read and write, without
TRUNCATE. Then count the triggers again; the count equals step 2’s:docker compose exec -T db \ pg_restore -U portfolixir -d portfolixir_prod --no-owner \ --role=portfolixir_owner --exit-on-error --single-transaction \ < ~/portfolixir-backups/portfolixir-before-roles.dump \ && echo "restore complete" docker compose exec -T db psql -U portfolixir -d portfolixir_prod -tAc \ "SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal"A restore that ends with an error leaves the database empty: find the cause and repeat steps 4 and 5. Start nothing before it printed
restore complete. -
Create
docker-compose.override.ymlas in step 3 of the new install above, then migrate as the owner and start the instance as the runtime role:docker compose run --rm --build migrate docker compose up --build -d
Compare the three figures as “Check the restore” below describes. Until
something new is written, the way back is the old shape: remove
docker-compose.override.yml and restore the step 2 backup as “Restore”
describes, which connects the instance as the bootstrap superuser again. The
move was checked with PostgreSQL 16’s own tools outside Compose, on a copy of
a migrated database: the restore as the owner kept every trigger and the
journal’s rows, every table and sequence belonged to the owner and was granted
to the runtime role, and the runtime role was refused TRUNCATE, switching a
trigger off, dropping one and creating a table.
Start
docker compose up --build
The first start builds the release image, runs the migrations and starts the application. Open the app through your reverse proxy, or directly on the host at:
http://127.0.0.1:4000
The MCP companion is exposed on localhost only:
http://127.0.0.1:4001/mcp
Through a published port, every client on the host reaches a container from
the one Docker bridge gateway address (see “Reverse proxy” below). The MCP
companion counts failed tokens per connecting address, as the application
counts failed logins, so every client on the host is one source to it: a
process on the host that sends wrong tokens locks that address after repeated
failures, for longer with each further one, up to a ceiling. Since Sprint 20
(#974) the lock answers only wrong tokens: your agent’s correct token passes
while it lasts, so a stale client that still sends an old token after a
rotation is refused without locking your agent out. The application’s API
checks its tokens the same way, since a release holds every token to 32 bytes
at boot; a server run from source that reads PORTFOLIXIR_API_TOKEN alone
holds it to no length floor, so while that token is shorter than 32 bytes a
locked address is refused before its token is compared, the correct one too.
The counts live in memory only:
docker compose restart mcp clears them at once.
CI runs this route on every change to what it builds: its compose-smoke job
runs docker compose up --build -d on generated secrets and an empty
database, and checks that the page, an API read and the MCP companion answer.
Reverse proxy
The application is reachable on the host’s loopback; a reverse proxy on the
same host (Caddy, nginx, Traefik) terminates TLS and forwards to
127.0.0.1:4000. It passes the
original Host header through (set PHX_HOST to that name) and sets the two
forwarding headers itself: it sets X-Forwarded-Proto to the scheme the
browser used, which is what marks the session cookie Secure and what
PHX_FORCE_SSL reads, and it appends the connecting address to
X-Forwarded-For or overwrites it with that address. It never passes a value
the client sent through: a forwarding header that reaches the application as
the client wrote it lets the client choose the throttle’s source.
Name the exact address the proxy connects from in
PORTFOLIXIR_TRUSTED_PROXIES. Through the published port that is the Docker
bridge gateway, which docker network inspect shows, for example
PORTFOLIXIR_TRUSTED_PROXIES=172.18.0.1. Docker picks that address when it
creates the stack’s network, so check it again whenever the stack’s network is
recreated, after a docker compose down for instance: an address that no
longer matches is as good as none. Name the one address rather than a
private block: every address inside a named block is believed, so a block also
trusts whatever else shares that network. The one address is shared too:
through a published port, every client on the host arrives from that same
address, not only the proxy, so naming it believes the forwarding headers of
every process on the host. Such a process can then choose the address the
throttle counts its failed logins under, or set the scheme. To believe the
proxy alone, attach the proxy to the stack’s network and name its container
address instead (docker network inspect shows it, and it too is checked
again when the network is recreated); otherwise name the gateway knowing that
every process on the host is trusted with it. With the address named, the
throttle
counts the client behind the proxy rather than the proxy: without it, ten
wrong passwords from anyone the proxy admits lock the login for everyone behind
it, the operator included. The same setting decides whose X-Forwarded-Proto is
believed: loopback and the named addresses only, so a proxy reaching the
container through the Docker bridge must be named there for the cookie to be
Secure and for PHX_FORCE_SSL to see HTTPS. Failed logins are also counted
across all sources together over a rolling window, so guesses spread over many
addresses cannot multiply; past that ceiling the login asks everyone to wait,
the operator included, while sessions already logged in keep working.
Restarting the application clears the counts, the ceiling’s included, because
they are kept in memory only: docker compose restart app gives the login
back at once. Changing PORTFOLIXIR_UI_PASSWORD in .env and running
docker compose up -d recreates the container, which clears them too.
Reverse-proxy authentication and the built-in UI password compose: keep
either, or both.
The TLS contract
TLS is the proxy’s job, in full. The application never terminates TLS and never redirects to HTTPS on its own, because its safe default is a loopback instance that has no certificate; what it offers is the opt-in. The contract, in four lines:
- The proxy terminates TLS and forwards plain HTTP to
127.0.0.1:4000. -
It passes
Hostthrough, setsX-Forwarded-Protoitself, and appends the connecting address toX-Forwarded-Foror overwrites it; it never passes a value the client sent through. It also forwards WebSocket upgrades on/live/websocket— the live pages run over that socket. Caddy’sreverse_proxydoes all of this by default. nginx needs:proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";HAProxy needs the lines below, with
option forwardforwritten withoutif-none, which would keep a header the client sent:option forwardfor http-request set-header X-Forwarded-Proto https if { ssl_fc } http-request set-header X-Forwarded-Proto http if !{ ssl_fc } - Once that is in place,
PHX_FORCE_SSL=truemakes the application redirect any plain-HTTP request it still sees to HTTPS and sendStrict-Transport-Securityon the HTTPS answers. Without the variable the application serves what it is given; with it, a proxy that forgetsX-Forwarded-Proto, or whose address is neither loopback nor named inPORTFOLIXIR_TRUSTED_PROXIES, produces a redirect loop, which is the variable telling you the header is missing or not believed. The one case the application can see for itself is named in a warning at startup:PHX_FORCE_SSLon, the listener bound beyond loopback (PHX_BIND_ALL, as in Compose) andPORTFOLIXIR_TRUSTED_PROXIESnot set, not readable, or naming no address beyond loopback in the listener’s IP family (an address of the other family is never matched). A second warning quotes every entry of the variable it could not read, whatever the other settings; the application starts all the same. The MCP companion calls the application over plain HTTP on the Compose network underapp, whichPORTFOLIXIR_FORCE_SSL_EXCLUDED_HOSTSexempts, so its calls are never redirected; a companion run outside Compose calls127.0.0.1, which is never redirected either. The companion refuses a redirect by name rather than following it (E25). - The proxy passes the application’s response headers through unchanged and injects nothing into the pages. Every page carries a Content-Security-Policy (next section); a proxy that adds a script or a stylesheet — a banner, an analytics tag — sees it blocked in the browser, and one that rewrites the header disables the protection.
Content Security Policy
Every browser page is served with a Content-Security-Policy header, built
per request. Scripts run only from the instance itself and from the three
boot scripts in the page’s own <head> and <body>, each admitted by a
nonce minted for that request; there is no inline event handler anywhere,
no eval, and no script from any other origin. Styles come from the
instance’s stylesheet plus inline style attributes (the pages colour
swatches and indent tree rows with them); images from the instance plus
data: URLs, which the chart export uses to draw its picture; connections
go to the instance and to its own WebSocket origin under the name the
browser addressed it by; nothing is embedded, nothing frames the pages from
another site, and forms post only to the instance.
Nothing needs configuring. Two things follow from it for an operator: the
proxy must pass the Host header the browser sent (the socket origin in the
policy is that name, so a proxy that rewrites Host leaves the live pages
unable to connect), and a page that renders but does not update, with
Content-Security-Policy errors in the browser console, means a proxy or a
browser extension injected script into it — the policy did its job.
Development stack
The development configuration — source mounted, Mix present, debug pages,
origin checks off, the database port published for local tooling — lives in
docker-compose.dev.yml and is never the documented deployment:
docker compose -f docker-compose.dev.yml up --build
It runs on public development secrets — the database password postgres and
the SECRET_KEY_BASE committed in config/dev.exs — so both of its ports, the
application’s and the database’s, are published on the host’s loopback only,
and it holds synthetic data only.
It is a Compose project of its own, portfolixir-dev, with a database volume
of its own (#932), so its containers and its reset never touch a production
stack started from the same checkout. The two publish the same ports on the
host, so one runs at a time. The development reset removes the development
database only:
docker compose -f docker-compose.dev.yml down -v
Moving off the development stack
Before the production release (Sprint 10, #760), the documented deployment was
this development stack, then named docker-compose.yml, with its ports open on
every interface. An instance still running that way moves to the production
stack by a backup and a restore, not in place: the production database is set
up only on an empty volume, and the old volume keeps the development user.
Such an instance, and a development stack started in a checkout before #932,
runs under the checkout directory’s Compose project, the one the production
stack uses, and keeps its database in the volume the production stack names
portfolixir-postgres-data. The development file names a project of its own
since #932 and no longer reaches that volume, so the commands below address
the old instance through docker-compose.yml. From the checkout, after pulling
the current version and writing .env (see “Secrets and settings” above):
# 1. A backup of the old instance's database, while it still runs.
umask 077
mkdir -p ~/portfolixir-backups
docker compose exec -T db \
pg_dump -U postgres -d portfolixir_dev --format=custom \
> ~/portfolixir-backups/portfolixir-dev.dump
# 2. The file lists its contents; only then remove the old containers and
# their database volume, which leaves the backup as the only copy.
docker compose exec -T db \
pg_restore --list < ~/portfolixir-backups/portfolixir-dev.dump | head
docker compose down -v
# 3. The production database, on a new volume.
docker compose up -d db
Then restore that file from step 3 of “Restore” below, and check the restore.
The old stack’s two build caches, app-deps and app-build, stay behind as
volumes under the checkout’s project name: docker volume ls lists them, and
docker volume rm removes them.
Backup and restore
The whole instance is one PostgreSQL database, so a backup is one pg_dump
file. It holds all of the instance’s data: securities, quotes and exchange
rates, portfolios, depots and cash accounts, every transaction, the
classifications with their assignments, the target plans with their category
and position targets and the cash targets, the policy rules, the research log
and events, tax records, and the audit journal. It does not hold .env —
keep a copy of that file somewhere safe as well: without POSTGRES_PASSWORD
and the tokens a restored database is still intact, but the instance has to be
configured anew. Nor does it hold the stored logos: they are files in the
portfolixir-logos volume, outside both the database and the release, and a
logo uploaded or chosen by hand exists only there, so the volume is backed up
beside the dump.
The commands below run from the directory that holds docker-compose.yml and
.env. No PostgreSQL tools on the host are needed: they run inside the db
container. They write the backups into ~/portfolixir-backups, outside the
checkout, under umask 077, so that only you can read them: a dump holds all
of the instance’s data. umask 077 holds for the rest of that shell session.
A copy that leaves this machine, on a disk or in a cloud folder: encrypt it
first, for example with age -p or gpg --symmetric.
Take a backup
Before the backup, note the three figures and the trigger count the restore will be checked against (see “Check the restore” below). Then:
umask 077
mkdir -p ~/portfolixir-backups
docker compose exec -T db \
pg_dump -U portfolixir -d portfolixir_prod --format=custom \
> ~/portfolixir-backups/portfolixir-$(date +%F).dump
The instance keeps running while the dump is taken; the file is a consistent snapshot of one moment. To see that the file is readable, list its contents:
docker compose exec -T db \
pg_restore --list < ~/portfolixir-backups/portfolixir-2026-09-23.dump | head
The stored logos, from the running application container:
docker compose exec -T app tar -C /var/lib/portfolixir/logos -cf - . \
> ~/portfolixir-backups/portfolixir-logos-$(date +%F).tar
Take a backup before every upgrade: migrations are additive, and a rollback across a release restores the backup taken before it.
Restore
A restore replaces the database with the backup. The application migrates the database when it starts, so it is stopped first and started only once the restore is complete — on a new host, start only the database for the same reason:
# 1. The database only (on a new host: `docker compose up -d db`).
docker compose stop app mcp
# 2. An empty database under the same name.
docker compose exec -T db dropdb -U portfolixir --if-exists portfolixir_prod
docker compose exec -T db createdb -U portfolixir portfolixir_prod
# 3. The backup, in one transaction: all of it or nothing.
docker compose exec -T db \
pg_restore -U portfolixir -d portfolixir_prod --no-owner --exit-on-error \
--single-transaction < ~/portfolixir-backups/portfolixir-2026-09-23.dump \
&& echo "restore complete"
# 4. The stored logos, into the logo volume while the application is still
# stopped: it checks every stored logo against that directory as it starts.
docker compose run --rm --no-deps -T --entrypoint tar app \
-C /var/lib/portfolixir/logos -xf - \
< ~/portfolixir-backups/portfolixir-logos-2026-09-23.tar
# 5. Only if step 3 ended without an error ("restore complete"): the
# instance, which migrates the restored database forward if the backup
# came from an older release.
docker compose up -d
The logos go back before the start because the application compares the
stored logos with the logo directory when it starts. Started first, it would
mark every logo whose file is not back yet as missing and, with background
fetching on, queue every discovered one for a new download. If the logos went
back only after the start, restart the application once they are
(docker compose restart app): that start finds the files and removes the
marks.
--single-transaction makes the restore all or nothing: at the first error
(--exit-on-error) everything it did is rolled back, and the database is left
empty rather than half filled — a half-filled one can lack the append-only and
audit-journal triggers that guard the records, which are restored with the
tables. Start the instance only after a restore that ended without an error:
on an empty database it would run the migrations and start without data. Find
the cause and repeat from step 2. A backup restores into the same or a newer
PostgreSQL major version; the db image in docker-compose.yml is the one to
use.
Check the restore
Compare three figures before the backup and after the restore: the total value, the number of holdings, and one position you know. On the Wealth page they are the total at the top, the rows of the Positions table and any one row of it. Compare, too, the number of the database’s triggers, the append-only and audit-journal guards among them; a restore that lost none shows the same number:
docker compose exec -T db psql -U portfolixir -d portfolixir_prod -tAc \
"SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal"
Over the API (the token is PORTFOLIXIR_API_TOKEN from .env; 1 is the
portfolio id the first call lists):
TOKEN=$(grep '^PORTFOLIXIR_API_TOKEN=' .env | cut -d= -f2-)
API=http://127.0.0.1:4000/api/v1
# The token reaches curl on its standard input, never on its command line,
# where every local user could read it in the process list.
api() { printf 'Authorization: Bearer %s\n' "$TOKEN" | curl -s -H @- "$API$1"; }
api /portfolios
api /portfolios/1/valuation # "total_value"
api /portfolios/1/holdings # one entry per holding
The figures are full-precision decimal strings, so a restore that lost nothing
shows them character for character the same. The procedure was run end to end
on 2026-09-23 against the synthetic review dataset with the shipped
docker-compose.yml: backup, a new database volume, restore, and the three
figures, the audit journal’s row count and the policy rules compared equal.
The single transaction and the trigger count came later (2026-09-24) and were
checked with the same PostgreSQL tools outside Compose, on a restore that
succeeds and on one that is refused.
Reset
A reset deletes the instance’s data: docker compose down -v removes the
stack’s volumes, the database volume portfolixir-postgres-data with every
record and the logo volume portfolixir-logos, and the next start sets up an
empty database. Take a backup first, as in “Take a backup” above, while the
instance still runs:
umask 077
mkdir -p ~/portfolixir-backups
docker compose exec -T db \
pg_dump -U portfolixir -d portfolixir_prod --format=custom \
> ~/portfolixir-backups/portfolixir-$(date +%F).dump
docker compose exec -T app tar -C /var/lib/portfolixir/logos -cf - . \
> ~/portfolixir-backups/portfolixir-logos-$(date +%F).tar
docker compose exec -T db \
pg_restore --list < ~/portfolixir-backups/portfolixir-$(date +%F).dump \
> /dev/null && echo "backup reads"
Only after it printed backup reads, reset. The backup is then the only copy
of the data, and “Restore” above brings it back:
docker compose down -v
docker compose up --build
Upgrade
Take a database backup before an upgrade (see “Backup and restore” above): migrations are additive, and a rollback across a release restores that backup.
After pulling a new version of the repository, fetch the images, rebuild and start:
docker compose pull db
docker compose build --pull
docker compose up -d
Every image the stack runs on is pinned by tag and digest in the repository —
the application’s Erlang/OTP and Debian base images, the MCP companion’s Node
base and the database image — so a fix in one of them arrives with the version
that moves its digest, and the release notes say when an upgrade carries one.
docker compose pull db and --pull fetch exactly the images the new version
names.
At every start the application compares the stored logos with the logo
directory. It marks a logo whose file is missing there, after an upgrade across
the move of the logos into the portfolixir-logos volume or with a lost or
unmounted volume, and records each mark in the audit journal. The mark changes
nothing else about the logo: its security shows its initials or flag instead of
a broken image and counts as without a logo on the Overview and the securities
page. A discovered logo is fetched again when background fetching is on
(PORTFOLIXIR_BACKGROUND_FETCH); a manual one stays locked, so discovery never
replaces it, and waits for you to upload it again. A later start that finds the
file back removes the mark. When the logo directory is missing or unreadable,
the application marks nothing and logs a warning instead; otherwise the log
names how many logos were marked.
Upgrading from 0.15.x or earlier, four changes of the security pass reach an
instance that already runs. Check them before the up:
- The proxy’s address.
X-Forwarded-Protois now believed only from loopback and from the addresses named inPORTFOLIXIR_TRUSTED_PROXIES. A reverse proxy that reaches the container through the Docker bridge, as every proxy does through the published port, is neither until it is named. Without it,PHX_FORCE_SSL=trueredirects every request in a loop, and withoutPHX_FORCE_SSLthe session cookie silently losesSecure. Name the bridge gateway, or the proxy’s own address on the stack’s network, before upgrading (“Reverse proxy” above). - One login. A session is now bound to the UI password, and a session issued by an earlier release carries no binding, so every browser logs in once after the upgrade.
- The MCP token’s floor. The companion now refuses a
PORTFOLIXIR_MCP_TOKENshorter than 32 bytes or equal to a placeholder, as the application already refused such an API token, and stops with the variable’s name. - The secret key’s floor. The application refuses a
SECRET_KEY_BASEshorter than 64 bytes, equal to a placeholder or equal to a value committed in this repository, and stops with the variable’s name. A newSECRET_KEY_BASEends every session.
Replace a refused value with the output of openssl rand -base64 48, as
“Secrets and settings” above describes.
Two migrations of the same pass check the data an instance already holds. The release migrates before it starts, so a migration that stops keeps the new release from starting:
- One position row per security in a plan. A plan that files one security
under two categories stops the upgrade with an error naming each plan,
security and
portfolio_targetsrow; which row stays is your choice. Restore the backup taken before the upgrade, start the previous release on it (check out its tag, build,up), remove all but one row of each named pair in its plan editor, take a new backup and upgrade again. Checking each plan for a security listed twice before the upgrade avoids the round trip. - Taxpayer and bank spellings. Holder and institution names recorded with
a no-break space, an invisible character or a decomposed letter are stored
the way a new write stores them, each change in the audit journal. A name
that would then equal another record of the same key is left as it was and
named in the log (
tax identity backfill); the upgrade goes on, and you correct or remove one of the two records on the Tax page.
Rebuild Derived Values
Expensive analytics (currently the daily performance walk) are kept as durable derived values (ADR-0039): pure recomputable materializations of the transaction ledger, versioned against every write. They can be dropped and rebuilt from the ledger at any time with one command, which reports its own runtime:
mix portfolixir.derived.rebuild
# inside the release container:
docker compose exec app bin/portfolixir eval "Portfolixir.Release.rebuild_derived()"
This never touches financial data — the ledger is read, never written. It is
the recovery step if a derived value is ever suspected stale or corrupt; in
normal operation invalidation is automatic. Freshness is always visible: the
performance chart’s basis line and every API/MCP performance payload carry
as_of and a stale flag.
Background refresh
A write does not only mark the affected figures stale — it schedules their recomputation. Shortly after a booking, an import or a quote update, the affected values are re-materialized in the background, so the next page you open shows a number instead of a “computing” cue. Bookings are collected and drained together: importing a large export costs one refresh, not one per row.
Two settings tune it, both in config/config.exs:
| Setting | Default | What it does |
|---|---|---|
quiet_ms |
500 |
How long the refresher waits for the writing to stop before recomputing. |
max_delay_ms |
10_000 |
The longest it will wait, so a continuous stream of writes still gets drained. |
The refresh is an optimisation of when the work happens, never of whether the number is right: a stale value is still recomputed on read, so a refresher that is slow, failing or switched off costs latency and never freshness.
Separate MCP Install
The MCP server is developed in this repository but can be installed and run
separately. npm ci installs exactly the versions in package-lock.json, and
--ignore-scripts keeps every dependency’s install-time script from running
on your machine — the same install CI and the companion’s image run:
npm ci --ignore-scripts --prefix mcp-server
npm run build --prefix mcp-server
Over stdio, the companion is started by the MCP client, not from a shell: the
client runs node mcp-server/dist/index.js with the API’s address and token in
its environment. Connect an Agent gives
the client configuration for each transport.
Versions And Rollback
Every merge that changes shipped code is tagged with a calendar version
(YYYY.M.N, the N-th release of that month; until September 2026 the
scheme was X.Y.Z, ending at 0.14.0) and published automatically as a
GitHub Release with generated notes and the API contract version it ships. A release is a
known-good point to pin or roll a self-hosted instance back to (check out the
tag before building) plus a readable changelog — never an installable
artifact. Migrations are additive; when rolling back across a release that
added migrations, restore the database backup taken before that upgrade.
Notes
- This setup uses the root
docker-compose.yml(a production release built fromDockerfile.release);docker-compose.dev.ymlis the development stack. - The web UI is open by default and locked by one variable
(
PORTFOLIXIR_UI_PASSWORD). A release started on its own binds loopback; in Compose the port mapping keeps it on the host’s loopback (“Reach” above). Either way it refuses foreignHostnames (ADR-0045). - The MCP companion wraps the local JSON API and does not access the database directly.
- The release logs at
info: the request lines, each live page’s socket connect with its CSRF token filtered out, warnings and errors. The database queries, the parameters of each request and page event, and the session contents that thedebuglevel writes stay out of the log. - Every request and every open page runs in its own process under a heap
cap of 512 MiB, counting the large binaries it holds, and so does every
task a page hands its work to (the performance walk, the valuations, an
import’s apply, a quote sync): a request that grows past it fails alone,
logged, instead of exhausting the machine’s memory. A page’s load runs in
a task linked to the page, so one that grows past it takes the page down
with it, logged, and the page reconnects; a quote sync and a logo lookup
run apart from the page and fail alone, the page answering with its
failure note. No ordinary read or write comes near it;
max_heap_bytesunderPortfolixirWeb.HeapCapinconfig/config.exschanges it. The in-memory cache of derived figures keeps to its own budget (5000 entries, 128 MiB). - The release starts without Erlang distribution, so it opens no listener
towards the other containers:
bin/portfolixir evalworks inside the container,remoteandrpcdo not. - This setup does not configure broker sync, bank sync, document intake (beyond the Portfolio Performance CSV/JSON import), trading, payments, orders, rebalancing, or LLM features.
- Public docs are published with GitHub Pages at
portfolixir.app.