Production Deployment
Caddy + Cloudflare Access + systemd + GitHub Releases dist distribution. This chapter covers the complete chain for "I want to run it on my own server."
← 07-extending · Wiki Index · 09-troubleshooting →
Topology Overview
Browser
↓ HTTPS
Cloudflare (DNS Orange Cloud + Access JWT Authentication)
↓ HTTP (Flexible mode, origin server doesn't need a certificate)
Your Server: Port 80 / 443
↓
Caddy Reverse Proxy (caddy-gateway container)
├─ /api/* → reverse_proxy 127.0.0.1:8765 (FastAPI invest-web.service)
└─ /* → file_server /srv/invest-gui/ (Static files)Why this setup:
- No SSR daemon → A single 1G VPS is sufficient (referencing the mc-website lesson:
next startcaused the entire machine to hang three times) - CF Access handles authentication at the edge → No auth code needed in the backend
127.0.0.1binding → Port 8765 is not exposed to the public internet- Caddy serves static files directly → GUI upgrade = rsync
dist/to/srv/invest-gui/, no reload required
0. One-Click Self-Hosting with Containers (Docker Compose / GHCR)
Don't want to manually install uv / configure systemd? Use containers. The image
ghcr.io/longsizhuo/openinvestis automatically published bypublish-image.ymlfor each backend version tag (v*). The GUI is baked intostatic/during the build phase—rundocker compose upand view the complete dashboard directly in your browser. To protect it with CF Access, you can still use the Caddy setup from Sections 1–2 below (reverse proxying the container's127.0.0.1:8765).
Prerequisites: Docker + Docker Compose v2.
git clone https://github.com/longsizhuo/openInvest.git && cd openInvest
cp .env.example .env && $EDITOR .env # At minimum, fill in DEEPSEEK_API_KEY (it can start without .env, but the committee won't run)Onboarding (creating memory/)——invest-agent (scheduler) will refuse to start if memory/user.md is missing. Use a one-time command via invest-web (the entrypoint: ["/bin/sh","-c"] in invest-agent will swallow additional arguments):
docker compose run --rm invest-web python -m scripts.skill init
# Or, in Claude Code, say "help me initialize invest" to go through the 5 questionsStart the service:
docker compose up -d --build # Local build (first time takes a few minutes: uv sync + build GUI)
# —— Or pull pre-built images (faster, requires the package to be Public or `docker login ghcr.io` first) ——
docker compose pull && docker compose up -dOpen http://localhost:8765 in your browser → Full GUI.
| Service | Role | Port |
|---|---|---|
invest-web | FastAPI + GUI (uvicorn bound to 0.0.0.0:8765) | Host 127.0.0.1:8765 (default binds only to loopback) |
invest-agent | Scheduler: runs jobs/*.yml (daily_report / pnl_snapshot…) | None |
- Expose to LAN/Public Internet: Change
invest-web'sportsto"8765:8765"and setINVEST_API_TOKEN; or keep it on loopback and put Caddy + CF Access in front (see Sections 1–2 below). - Data Persistence:
memory/(ledger, required) /db//cache_data//logs/are all bind-mounted to the host, so data survives container recreation. - Image Visibility: GHCR packages are private by default—to pull anonymously with
docker compose pull, you must set the package to Public on GitHub Packages (or usedocker login ghcr.io).
1. One-Time Server Configuration
1.1 Install uv + Clone Repository
curl -LsSf https://astral.sh/uv/install.sh | sh
git clone https://github.com/longsizhuo/openInvest.git ~/openInvest
cd ~/openInvest
uv sync --frozen --python 3.13
cp .env.example .env
# Edit .env to fill in DEEPSEEK_API_KEY / EMAIL_* / etc.1.2 Pull Frontend Dist
uv run python -m scripts.sync_gui_dist
# = Pulls invest-gui-dist.tar.gz from the invest-gui dist-latest release
# = Extracts to static/ (used by FastAPI's built-in GUI mount)There are two GUI deployment methods, they coexist without conflict:
| Path | Served By | Upgrade Method |
|---|---|---|
<repo>/static/ | FastAPI mount | python -m scripts.sync_gui_dist (pull release) |
/srv/invest-gui/ | Caddy file_server | cd invest-gui && pnpm deploy (rsync local build) |
Production Caddy prefers the latter; FastAPI mount is for single-machine one-click scenarios.
1.3 systemd Unit
Copy the unit file provided in the repository:
sudo cp systemd/invest-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now invest-web
sudo systemctl status invest-webKey fields in the unit:
WorkingDirectory=%h/openInvest
EnvironmentFile=%h/openInvest/.env
ExecStart=%h/.local/bin/uv run --no-sync uvicorn connectors.web_api:app --host 127.0.0.1 --port 8765
Restart=on-failure
ProtectSystem=strict
ReadWritePaths=%h/openInvest→ Only writes to the invest directory (systemd hardening). → Restart=on-failure: Automatically restarts if the process crashes, but you must manually restart to load new code after changes.
1.4 Caddy Site Configuration
Add to caddy-gateway/Caddyfile:
http://invest.your-domain.com {
encode gzip
handle /api/* {
reverse_proxy 127.0.0.1:8765
}
handle {
root * /srv/invest-gui
@viteAssets path /assets/*
header @viteAssets Cache-Control "public, max-age=31536000, immutable"
@html path *.html /
header @html Cache-Control "no-cache"
file_server
try_files {path} /index.html # SPA route fallback
}
}After modifying the Caddyfile, you must restart (not reload)—reload doesn't read new inodes in bind mount mode:
docker restart caddySee memory feedback_caddy_bind_mount_reload for details.
2. Cloudflare Access Configuration
2.1 DNS
CF Dashboard → DNS → Add an invest A record pointing to your server IP, enable the orange cloud (route through CF proxy).
2.2 Access Application
CF Zero Trust → Access → Applications → Add a self-hosted application:
| Field | Value |
|---|---|
| Application name | invest |
| Application domain | invest.your-domain.com |
| Session duration | 30 days (or 24h, as you prefer) |
| Identity providers | One-time PIN (email) |
2.3 Access Policy
Action: Allow
Include: Emails → your-email@gmail.com→ Only your email can access.
2.4 Verification
- Log out of all CF email sessions
- Visit
https://invest.your-domain.comin your browser - You should be redirected to the CF email verification page → Enter OTP → Access the GUI
3. Upgrade Process
Upgrade Backend
cd ~/openInvest
git pull origin main
uv sync # If there are dependency changes
sudo systemctl restart invest-webImportant: The systemd unit does not automatically reload new code. You must restart after every push.
Upgrade Frontend
# Option A: Pull from GitHub Releases
cd ~/openInvest
uv run python -m scripts.sync_gui_dist
# Note: Only updates static/, does not touch /srv/invest-gui/
# Option B: Local build + rsync to Caddy serve directory (for production)
cd ~/invest-gui
git pull origin main
pnpm install
pnpm deploy # = scripts/deploy.sh, rsync dist/ → /srv/invest-gui/Caddy does not need a reload (static file changes). The browser might cache the HTML (even though the Caddyfile sets no-cache) → Users should hard refresh once.
Upgrading Both Frontend and Backend Simultaneously
The safest order:
- Push frontend and backend code
- Deploy frontend first on the server (
pnpm deploy) — Users get the old API with the old frontend, works fine git pullbackendsudo systemctl restart invest-web— Switch to the new API- Users hard refresh → Frontend loads new hashed JS → Calls new API ✓
Why frontend first: A new frontend calling the old API won't crash (backward compatible fields), and an old frontend calling the new API also won't crash. Conversely: An old frontend calling an endpoint that the new frontend depends on will get a 404.
4. Environment Variables
Required
DEEPSEEK_API_KEY=sk-xxx
DEEPSEEK_BASE_URL=https://api.deepseek.com
INVEST_WEB_HOST=127.0.0.1
INVEST_WEB_PORT=8765Optional
# CommSec Email Import (for Australian stock users)
EMAIL_SENDER=you@gmail.com
EMAIL_PASSWORD=app-password-16-chars
# Send email after committee runs
SMTP_HOST=smtp.gmail.com
SMTP_USER=you@gmail.com
SMTP_PASS=app-password
SMTP_TO=you@gmail.com
# NapCat QQ Bot
NAPCAT_WS_URL=ws://localhost:6101
NAPCAT_HTTP_URL=http://localhost:6100
INVEST_WHITELIST_QQ=12345678 # Required, otherwise napcat rejects all
# Committee behavior switches (can also be changed at runtime via GUI/API/CLI, ADR-017; env is only the deployment default)
# Concentration lens: false=Single asset/intentional concentration/full investable pool not advised to reduce due to concentration (ADR-019)
INVEST_VERDICT_CONCENTRATION_LENS_ENABLED=true
# Development environment (Vite cross-origin calls)
INVEST_WEB_DEV_CORS=1 # Only use for local dev, do not enable in production.env.example is the Source of Truth
New env vars must be added to .env.example, otherwise fork users won't know about them.
5. Hardening Checklist (Server hardening 2026-05)
Refer to memory project_server_2026_05_02_hardening:
- ✅ rpcbind / portmapper disabled
- ✅ unattended-upgrades for automatic security patches
- ✅ All 5 services bound to 127.0.0.1 (ports not exposed to the public internet)
- ✅ wcpp service stopped (no longer used)
- ✅ Caddy only allows invest.* longsizhuo.com and mc.involutionhell.com
- ✅ ufw only allows ports 22 / 80 / 443
- ⏳ TODO: Caddy CF IP whitelist (prevent bypassing CF to connect directly to the origin server)
6. systemd Service List
sudo systemctl list-units --type=service | grep invest
# invest-web.service uvicorn FastAPI :8765
# invest-scheduler.service APScheduler runs all jobs/*.ymlinvest-scheduler.service (if you run cron) unit is similar:
ExecStart=%h/.local/bin/uv run --no-sync python -m scheduler.runnerSee systemd/README.md and scheduler/README.md for details.
7. Backup Strategy
Authoritative State is Split Across Two Stores—Don't Just Look at memory/
"Money" isn't only in memory/. There are two pieces of authoritative state that cannot be rebuilt, they must be backed up together:
| Authoritative State | What it is | Consequence of Loss |
|---|---|---|
memory/portfolio.md | Current holdings + cash | Lose "what I currently hold" |
db/trades.db | Transaction ledger (planned→executed) | Lose 9 months of transaction history |
memory/{.committee,insights,daily,.dreams} | Historical decisions / insights | Theoretically regenerable, but requires a lot of LLM tokens |
.env | Credentials + configuration | Re-apply / re-fill |
⚠️ Common Misconception: Thinking the entire
db/is a disposable market data cache.db/trades.dbis a ledger, not a cache—migrating with the assumption that "db/ doesn't need backup" will lose the entire transaction history. Only the databases listed in the "No Backup Needed" section below are disposable.
One-Click Snapshot / Migration (Recommended)
scripts/snapshot.py packages the above authoritative state into a single tar.gz (WAL-safe sqlite online backup + sha256 checksum). A single command on the new machine restores everything. Use this to migrate the hub to a new machine:
# Old machine: Package (does not include .env secret values, only lists which keys need to be filled in the manifest)
uv run python -m scripts.snapshot snapshot --out ~/invest-snapshot.tar.gz
# New machine: After clone + uv sync, restore (default refuses to overwrite existing ledgers, --force to overwrite)
uv run python -m scripts.snapshot restore --in ~/invest-snapshot.tar.gzOffsite Backup (Weekly Automatic)
~/openinvest-research-archive/refresh.sh (cron 0 3 * * 0) pushes decisions/insights and the ledger (portfolio.md + trades.db, placed in invest_ledger/) to the private repo openinvest-research-archive every week. This is currently the only off-machine copy—don't accidentally delete that cron job.
Daily Local Cold Backup (Optional, Recommended)
# Daily cron—directly reuses snapshot.py, one tar containing all authoritative state
0 4 * * * cd $HOME/openInvest && $HOME/.local/bin/uv run python -m scripts.snapshot snapshot --out /backup/invest-$(date +\%F).tar.gzCloudflare Access Configuration
The Access policy on the CF Dashboard side doesn't have a git backup. It's recommended to manually take a screenshot of the policy JSON.
No Backup Needed (Auto-rebuilt on first run on new machine)
db/market_data.db/db/events.db(Market data + news cache, can be re-fetched)db/chroma.sqlite3/db/jobs.sqlite(Vector store / scheduler job store, can be rebuilt)cache_data/(HTTP cache)static/(Frontend dist,scripts.sync_gui_distre-pulls it)memory/.backtest*(Backtest research artifacts, not a ledger)
8. Monitoring (Optional)
Backend logs:
sudo journalctl -u invest-web -f --since "1 hour ago"CF Access logs: CF Zero Trust → Logs → Access.
GUI data source health: https://invest.your-domain.com/system → "Data Sources" tab.
9. Multi-Device: Hub-and-Spoke Remote Mode (2026-06)
One machine (hub) holds the single memory/ and runs web_api; other devices (laptop/another dev machine) set INVEST_API_BASE in their CLI / Claude Code skill, and all subcommands are forwarded to the hub via HTTP for reads and writes—locking is still the single-machine fcntl on the hub, zero distributed complexity. Centralized parameter tuning is automatic: strategy / role prompts are all on the hub, changing one place affects all devices.
Laptop (client) Hub (local machine/VPS)
run.sh status ──HTTP──┐ invest-web.service :8765
run.sh buy ... ├──────────────▶ ├─ /api/skill/* (CLI equivalent endpoints)
prepare_committee ────┘ ├─ /api/committee/* (prepare/save/run)
(local machine has zero memory/) └─ memory/ ← Single ledger, fcntl lockHub Side (Zero Required Changes for Existing Deployments)
If you already have the Caddy + CF Access deployment, you don't need to change anything—the client can use a CF Access Service Token to pass through the edge (recommended, see below). Only for LAN scenarios without CF:
# .env
INVEST_API_TOKEN=<openssl rand -hex 24> # Enable application-layer authentication
INVEST_WEB_HOST=0.0.0.0 # Bind outside loopback (for direct LAN connections)Token semantics: Access to /api/* (excluding /api/health) from non-loopback sources requires Authorization: Bearer; behavior is completely unchanged if the token is not set.
Client Side (2 minutes)
# clone + uv sync as usual (run.sh auto-runs on first run), then .env only needs:
INVEST_API_BASE=https://invest.your-domain.com # or http://10.0.0.x:8765
INVEST_API_TOKEN=... # Only needed if hub has it enabled
~/.claude/skills/invest/scripts/run.sh doctor # Verify: status ready + remote sectionThe client has no memory/, and doesn't need a DeepSeek key / Gmail credentials. init is disabled in remote mode; run_committee runs on the hub (CLI auto-polls); live_prices / correlate still run locally. Write operations go to the hub's ledger, history is recorded with source: skill_remote.
Recommended: Cloudflare Tunnel + Access Service Token (Hub Doesn't Open Public Ports)
- CF Zero Trust → Access → Service Auth → Create Service Token, get Client ID / Secret
- Add a
Service Authinclude rule to the Access Application (invest domain) Policy - Client .env:bashThe remote dispatch will automatically include the
INVEST_API_BASE=https://invest.your-domain.com CF_ACCESS_CLIENT_ID=xxx.access CF_ACCESS_CLIENT_SECRET=yyyCF-Access-Client-Id/Secretheaders; browser users still go through SSO. The hub continues to bind only to 127.0.0.1, zero backend changes.
Known Limitations
- Synchronous committee runs can block the uvicorn event loop for 5-10 minutes (an existing limitation, amplified by remote mode): the hub should use
--workers 2for multi-process deployment; all long tasks should use a "trigger + poll" approach to avoid hitting the CF ~100s proxy timeout. - The "same-day cache" between client and hub is based on the hub's date (taken from the
/api/healthtimestamp), so cross-timezone devices won't get out of sync.
Next Steps
→ 09-troubleshooting.md — Where to look if the deployment fails
→ adr/ — Why this deployment model (no SSR / no K8s)