EN DE

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.1 directly, 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 .env file 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.1 is out of its reach. Build with the host’s network, --network host (under Compose, network: host in the service’s build:), 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 as http://mirror.example/. Under DEBIAN_MIRROR the mirror serves the Debian archive at debian (the suites bookworm and bookworm-updates); under DEBIAN_SECURITY_MIRROR, the security archive at debian-security (bookworm-security). Many public mirrors carry no debian-security, so the second names one that does; left empty, it is DEBIAN_MIRROR. Give the base, not the archive path: http://ftp.example.org/, not http://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. An https:// mirror is verified against build_ca, or without it against the stage’s own CA store; the runtime stage has none before it installs ca-certificates, so there the build stops before apt with one line. Pass a CA bundle as the secret, or name the mirror with http://. 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 save and docker 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.

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 no TRUNCATE, 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.

  1. Add two passwords to .env, each from openssl rand -hex 32: PORTFOLIXIR_OWNER_DB_PASSWORD and PORTFOLIXIR_APP_DB_PASSWORD.
  2. Start the database alone and create the roles. The passwords reach psql on 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;
    SQL
    

    The 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 TRUNCATE never is.

  3. Create docker-compose.override.yml beside docker-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-off migrate service 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}
    
  4. 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.

  1. Add the two passwords to .env, each from openssl rand -hex 32: PORTFOLIXIR_OWNER_DB_PASSWORD and PORTFOLIXIR_APP_DB_PASSWORD.
  2. 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.

  3. Create the two roles. The passwords reach psql on 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
    
  4. 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
    
  5. 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.

  6. Create docker-compose.override.yml as 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:

  1. The proxy terminates TLS and forwards plain HTTP to 127.0.0.1:4000.
  2. It passes Host through, sets X-Forwarded-Proto itself, and appends the connecting address to X-Forwarded-For or 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’s reverse_proxy does 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 forwardfor written without if-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 }
    
  3. Once that is in place, PHX_FORCE_SSL=true makes the application redirect any plain-HTTP request it still sees to HTTPS and send Strict-Transport-Security on the HTTPS answers. Without the variable the application serves what it is given; with it, a proxy that forgets X-Forwarded-Proto, or whose address is neither loopback nor named in PORTFOLIXIR_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_SSL on, the listener bound beyond loopback (PHX_BIND_ALL, as in Compose) and PORTFOLIXIR_TRUSTED_PROXIES not 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 under app, which PORTFOLIXIR_FORCE_SSL_EXCLUDED_HOSTS exempts, so its calls are never redirected; a companion run outside Compose calls 127.0.0.1, which is never redirected either. The companion refuses a redirect by name rather than following it (E25).
  4. 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-Proto is now believed only from loopback and from the addresses named in PORTFOLIXIR_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=true redirects every request in a loop, and without PHX_FORCE_SSL the session cookie silently loses Secure. 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_TOKEN shorter 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_BASE shorter than 64 bytes, equal to a placeholder or equal to a value committed in this repository, and stops with the variable’s name. A new SECRET_KEY_BASE ends 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_targets row; 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 from Dockerfile.release); docker-compose.dev.yml is 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 foreign Host names (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 the debug level 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_bytes under PortfolixirWeb.HeapCap in config/config.exs changes 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 eval works inside the container, remote and rpc do 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.