The dashboard has no authentication. Anyone who can reach port 8501 can read your entire ledger and add or delete trades. This is deliberate — it keeps a single-user tool simple — and it means the network is your only access control.
The defaults bind to every interface on the host:
| Surface | Default binding | Reachable from |
|---|---|---|
| Dashboard | 0.0.0.0:8501 |
every network the host is on |
| Postgres | 0.0.0.0:54320 |
every network the host is on |
| MCP server | 127.0.0.1:8765 |
the host only |
pgAdmin (tools profile) |
127.0.0.1:58080 |
the host only |
On a home machine behind a router that is "your LAN". On a VPS, a machine with a public address, or a host that is also on a VPN, it is every one of those networks at once — a wildcard bind is only as private as the host's least private interface. Two things follow: never port-forward 8501 on your router, and if any network the host sits on has guests or devices you don't control, tighten the binding.
Copy docker-compose.override.yml.example to docker-compose.override.yml and
bind to the loopback interface:
services:
dashboard:
ports: !override
- "127.0.0.1:8501:8501"
postgres:
ports: !override
- "127.0.0.1:54320:5432"The !override tag is not decoration. Compose merges an override file's
ports into the base file's list rather than replacing it, and it treats a
different host IP as a different entry — so without the tag the loopback
mapping lands beside the inherited 0.0.0.0:8501, and the dashboard stays
open to the network while the file says otherwise. !override replaces the
list (Compose 2.24.4 or newer; docker compose version tells you). A test in
this repository renders these examples and fails if a wildcard mapping
survives, because the plain-list form looked right and was not.
Then reach it over SSH from another machine:
ssh -N -L 8501:127.0.0.1:8501 you@your-host
# now open http://localhost:8501The least you can get wrong. Install Tailscale on the host and on your phone or laptop; reach the dashboard at the host's tailnet address. No port is ever exposed to the internet, and access is authenticated by the mesh.
# with tailscale on the host, keep the service bound to the LAN and
# let the tailnet address do the work — no reverse proxy neededIf you want a real hostname and TLS, terminate both in a proxy and require a login in the proxy — the app will not do it for you. Caddy, with basic auth:
portfolio.example.com {
basic_auth {
# generate with: docker run --rm caddy caddy hash-password
you $2a$14$replace-with-your-own-hash
}
reverse_proxy 127.0.0.1:8501
}Streamlit uses websockets; Caddy proxies them without extra configuration. On
nginx you must pass Upgrade/Connection headers through explicitly, which is
the usual reason a proxied dashboard loads and then hangs on "Connecting".
Whatever you use: bind the dashboard to 127.0.0.1 so the proxy is the only
path in, and prefer an identity-aware proxy (Cloudflare Access, Authelia,
oauth2-proxy) over basic auth if more than one person could plausibly find the
hostname.
Don't. There is no login, no rate limiting, and no audit trail; the ledger is writable by anyone who loads the page.
It binds to 127.0.0.1 and requires a bearer token
(PORTFOLIODB_MCP_TOKEN). It connects to the database as a read-only role
that holds SELECT and nothing else — make ro-role creates it and prints
the two .env lines (PORTFOLIODB_MCP_RO_USER / PORTFOLIODB_MCP_RO_PASSWORD).
The role is required: without it the server refuses to connect and says how to
create it. That makes "read-only" a property of the database rather than a
promise of the code — a session setting can be switched off by any statement,
a missing grant cannot. The escape hatch for a deliberate operator is
PORTFOLIODB_MCP_ALLOW_RW_FALLBACK=1, which runs the server on the
application's read-write credentials and logs a warning on every start.
The MCP container also receives only the environment it needs (the database's
address, its own settings) rather than the whole .env: the LLM keys and the
vendor API key have no business in a process that answers an LLM over the
network — and neither does the application's read-write password, which the
mcp service is not given at all. The read-only role is the only login it
holds. If you take the fallback, you have to hand it the password yourself,
in docker-compose.override.yml:
services:
mcp:
environment:
PORTFOLIODB_MCP_ALLOW_RW_FALLBACK: "1"
PORTFOLIODB_PASSWORD: ${POSTGRES_PASSWORD}Without that block the fallback refuses to start and says which line is missing, rather than running on a password it was quietly handed.
To let an agent on another machine reach it, tunnel rather than publish:
ssh -N -L 8765:127.0.0.1:8765 you@your-hostIf you must publish it, put it behind the same authenticating proxy as the dashboard and treat the token as one factor, not the only one.
Port 54320 is published so host tools and the CLIs can reach it. If nothing
outside Docker needs it, remove the mapping in your override — the other
services talk to postgres over the compose network regardless:
services:
postgres:
ports: !reset []An empty list without the !reset tag removes nothing: Compose merges it
with the base file's list and the port stays published. Check the result with
docker compose config — the postgres service should show no ports key.
- Nothing forwarded from the router to 8501 or 54320
- Dashboard bound to
127.0.0.1if the LAN isn't trusted — anddocker compose configconfirms no0.0.0.0mapping is left beside it - Remote access via tailnet or an authenticating proxy, never raw
-
PORTFOLIODB_MCP_TOKENis long and random (make init/.\pdb.ps1 initgenerates one) -
portfoliodb_rorole created, so the MCP surface can't write -
.envis not world-readable (chmod 600 .env) - Backups exist and live somewhere other than this host (see operations.md)