Kamal 2 deploy to the existing ecosystem VPS, alongside the legacy services (dividend-portfolio, pulse, logo-service, trends). Everything quantic is namespaced and additive; the legacy stack has no backups yet, so the rules below are not optional.
- Never run
kamal proxy reboot/kamal proxy remove. kamal-proxy is shared with the legacy apps; killing it takes quantic.es, pulse.quantic.es, and logos.quantic.es down with it.kamal deployreuses the running proxy and only registers thebeta.quantic.esroute. - Never touch legacy volumes (
pulse_data,trends_postgres_data, the logo-service volume, dividend-portfolio storage). Quantic's volumes arequantic_logosandquantic_postgres_data— nothing else. - The Postgres accessory binds to
127.0.0.1:5434. No other Postgres runs on the host today (trends — the only legacy service with one — was never deployed), but if you change the port, checkdocker pson the host first. kamal accessory remove dbdeletes the container, not the data directory — but don't run it casually either.
-
Local tooling (mise-managed):
mise install(Ruby is in.tool-versions), thengem install kamal -v 2.7.0— pinned: newer kamal demands a newer kamal-proxy and refuses to deploy, suggestingkamal proxy reboot, which is forbidden here (shared proxy, see safety rules). Same pin as pulse's CI. Upgrading kamal ecosystem-wide is a deliberate maintenance event for after backups exist. The executable lands on PATH via mise shims. ThebwCLI:npm install -g @bitwarden/cli, then point it at the EU server (the account lives there — the US default rejects the API key with "client_id or client_secret is incorrect"):bw config server https://vault.bitwarden.eu
And SSH: the repo never carries the server's address —
deploy.ymltargets thequantic-vpsalias, which each machine defines in~/.ssh/config:Host quantic-vps HostName <the VPS IP — from your password manager> User rootThen add this machine's key (
ssh-copy-id quantic-vps), or kamal prompts for the root password on every connection. -
DNS:
beta.quantic.esA record → the VPS IP. -
Bitwarden item
quantic-phoenixwith:KAMAL_REGISTRY_PASSWORD,SECRET_KEY_BASE(mix phx.gen.secret),POSTGRES_PASSWORD,DATABASE_URL(ecto://quantic:<POSTGRES_PASSWORD>@quantic-db/quantic_phoenix),GOOGLE_OAUTH_CLIENT_ID/SECRET,RESEND_API_KEY,EVENTS_API_KEYS. -
Google OAuth console: add
https://beta.quantic.es/auth/google/callbackto the authorized redirect URIs. -
Shell env for kamal: only
BW_ACCOUNTis strictly required for local deploys — when the vault is locked, thebwCLI prompts for the master password interactively. The other three vars in.env.example(BW_CLIENTID,BW_CLIENTSECRET,BW_PASSWORD) exist for the non-interactive path —bw login --apikey+export BW_SESSION=$(bw unlock --raw --passwordenv BW_PASSWORD)— which is how pulse's GitHub Actions deploy works, and what a future quantic deploy workflow would use (vars live in Actions secrets there). -
Every field must exist in the Bitwarden item with a non-empty value — Bitwarden stores empty fields as null and kamal's adapter raises "Could not find secret" on them. If you don't have a real value yet:
EVENTS_API_KEYS→ generate one (openssl rand -hex 32, doubles as your curl probe key);RESEND_API_KEY→ the literal stringdisabled(only read at email-send time, so magic links fail gracefully and everything else works).
kamal setup # boots the db accessory + app; reuses the running proxykamal setup is idempotent on a host that already runs kamal apps — it
will NOT reinstall or restart the shared proxy.
Merging a PR to main deploys automatically (.github/workflows/deploy.yml,
gated on CI passing, native ARM runner). Manual deploys remain possible
from any machine set up per this doc:
kamal deployBW_ACCOUNT,BW_CLIENTID,BW_CLIENTSECRET,BW_PASSWORD— the same four Bitwarden vars as local (.env.example).SSH_PRIVATE_KEY— a dedicated deploy key (not a personal one); its public half goes in the VPS rootauthorized_keys.VPS_HOST— the server address (kept out of the repo, same rule as the localquantic-vpsalias).
Migrations run automatically on boot (bin/server runs
Quantic.Release.migrate before starting the endpoint), so a deploy
can never serve code against a stale schema. bin/migrate still exists
for running them standalone.
- Migrations: automatic on boot; standalone via
bin/migrateorbin/quantic eval "Quantic.Release.migrate". - Legacy imports run via eval, e.g.
bin/quantic eval 'Quantic.Logos.LegacyImport.run("/app/data/import/logos.db")'. - Logo files:
rsyncthe legacy volume intoquantic_logos(host-side copy between/var/lib/docker/volumes/...paths) — the on-disk layout is identical by design. - Remote shell:
kamal app exec -i 'bin/quantic remote'.
Run against copies of legacy data, never the live files:
- Logos: rsync a copy of the volume +
LegacyImport.run/1(idempotent — re-run freely). - Visit analytics:
Community.import_legacy_visits/2increments on conflict (correct once at cutover) — on rehearsal re-runs, truncatecommunity_visitsfirst. - dividend-portfolio users/stocks/holdings/radar:
bin/quantic eval 'Quantic.LegacyImport.run("/app/data/import/production.sqlite3") |> IO.inspect()'— idempotent by natural keys (email / symbol / user+stock); re-running applies snapshot-wins semantics (beta-side edits to imported fields are rolled back). Copy the SQLite snapshot into the volume first, e.g.scpto the host +docker cpinto the container's/app/data/import/.
The flip is a kamal-proxy host re-route, NOT a DNS change. Legacy
(dividend-portfolio) and the monolith run on the same VPS, same IP;
the shared kamal-proxy demultiplexes by Host header. The quantic.es
and www.quantic.es A records already point at this IP (that's how
legacy serves them today), so no DNS record changes. Cutover =
telling kamal-proxy that quantic.es now targets the monolith container
instead of the legacy one. kamal-proxy keys routes by host and reassigns
on deploy (last-writer-wins, with draining) — the same mechanism it uses
for zero-downtime within an app.
Why this beats a DNS flip: the swap is atomic at the proxy (no TTL / propagation wait) and instantly reversible (reassign the host back to legacy — standby still holds the frozen data).
- Google OAuth console: add
https://quantic.es/auth/google/callbackandhttps://www.quantic.es/auth/google/callbackto the authorized redirect URIs (keep the beta one until standby ends). - Stage the deploy.yml change (don't deploy yet) — see step 5.
- Rehearse the data import once more against a fresh snapshot (above).
- Read-only legacy — banner + disable mutating endpoints on
dividend-portfolioso no writes land after the final snapshot. - Final snapshot of the legacy SQLite (
sqlite3 … ".backup …"). - Final import —
docker cpthe snapshot into the monolith container, thenbin/quantic eval 'Quantic.LegacyImport.run("/app/data/import/production.sqlite3") |> IO.inspect()'(idempotent, snapshot-wins). Also: logos rsync intoquantic_logos+Quantic.Logos.LegacyImport.run/1, and visit analytics viaCommunity.import_legacy_visits/2(truncatecommunity_visitsfirst if it was populated in a rehearsal). - Telegram flip (the monolith ships dormant and never registers on
boot — this is the one explicit step that detaches legacy's webhook
and attaches ours; same bot token). Prereq:
TELEGRAM_BOT_TOKEN,TELEGRAM_BOT_HANDLE,TELEGRAM_WEBHOOK_SECRETin Bitwarden +TELEGRAM_NOTIFICATIONS: "true"indeploy.yml(already wired) and the three vars added to.kamal/secrets's fetch+extract list (kamal hard-fails the deploy otherwise — "Secret … not found in .kamal/secrets"). Then, after the deploy, userpcnoteval— these run on the live server node (which has Repo + Finch started);evalboots a bare node whereregister_webhook's HTTP client isn't running:Verify by sending the bot a# carry the active links (matched to users by email; idempotent) docker exec "$QUANTIC" bin/quantic rpc \ 'Quantic.Telegram.LegacyImport.run("/tmp/cutover.sqlite3") |> IO.inspect()' # attach the webhook to the monolith (detaches it from legacy) docker exec "$QUANTIC" bin/quantic rpc \ 'Quantic.Telegram.Client.register_webhook("https://quantic.es/telegram/webhook") |> IO.inspect()'
/startand a question; the digest cron then runs at 06:30 UTC. (The snapshot must still be in the container — it's the sameproduction.sqlite3, which also holds the links.) - The flip — edit
config/deploy.yml:andproxy: ssl: true hosts: - quantic.es - www.quantic.es - beta.quantic.es # keep during standby
PHX_HOST: quantic.es(+ theenv.clear.PHX_HOST) so generated URLs, magic-link emails, and share links use the real domain. Thenkamal deploy. kamal-proxy reassignsquantic.es/wwwto the monolith and provisions Let's Encrypt certs for them (apex already resolves here → ACME http-01 passes; first HTTPS hit is slightly slower — pre-warm withcurl https://quantic.es/up). Neverkamal proxy reboot—kamal deployreuses the running shared proxy (safety rule). - Release the host on the legacy side — drop
quantic.es/wwwfrom the legacy app's deploy.yml so a future legacy deploy can't re-claim them (no immediate legacy redeploy needed; the monolith deploy already took the route). - Smoke-test on
quantic.es: sign in (OAuth), portfolio renders with live prices,/dividends, public/p//rshare-image capture, Telegram/start. - Retire the rest — reassign
pulse.quantic.es→ the monolith (/community) the same way if desired;logos.quantic.esis just dropped (consumers are in-app now).
Leave the legacy containers running, no traffic. To roll back: reassign
the host at the proxy —
kamal-proxy deploy --host quantic.es --target <legacy-container> (or
redeploy legacy claiming the host) and remove it from the monolith. The
legacy standby has the frozen pre-cutover data. After the standby window
with no issues, tear down legacy containers (keep volumes until backups
are confirmed).