Coolify: deploy the stack
Prerequisite: the VM from Oracle Cloud setup with
ports 80/443/8000 open in both firewalls. Operating mode:
domain-deferred. VM_IP = the VM's public IP.
1. Install Coolify
ssh vambora-vps
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash
Open http://VM_IP:8000, create the admin user. Connect a Git source:
GitHub (OAuth) once the repos exist, or "Deploy from a Dockerfile" /
private repo deploy key. The repos already contain backend/Dockerfile
(Python 3.12, EXPOSE 8000, CMD python -m vambora.main) and
web/Dockerfile — the web normally goes on Cloudflare Pages (step 6), not
Coolify.
2. Create a project + the data services
In one Coolify project (one Docker network so services reach each other
by name). Match the images in backend/docker-compose.yml:
| Service | Image | Notes |
|---|---|---|
db | timescale/timescaledb-ha:pg16 | env POSTGRES_USER=postgres, POSTGRES_PASSWORD=<generate>, POSTGRES_DB=vambora; persistent volume on /home/postgres/pgdata; do not publish a public port |
redis | redis:7-alpine | no public port |
otp | opentripplanner/opentripplanner:2.5.0 | see step 3 |
Generate a strong Postgres password in Coolify (not postgres). No service
except the API is publicly exposed.
3. OpenTripPlanner (mind the graph build)
OTP needs a built graph.obj. Per plan.md "Appendix: OTP / Routing" the
build is memory-hungry — but the A1 VM has 24 GB, far above the 7.7 GB
local ceiling, so a normal build works here.
- Mount a persistent volume at
/var/opentripplannercontainingrio.osm.pbf,rio-gtfs.zip, andbuild-config.json(copy these up:scp -r backend/otp vambora-vps:/srv/vambora-otpthen mount that path). - First run: command
["--build", "--save", "--serve"], envJAVA_TOOL_OPTIONS=-Xmx12g. It writesgraph.objinto the volume (a few minutes). - Steady state: change the command to
["--load", "--serve"](seconds, low memory). Healthcheck:wget -qO- http://localhost:8080/otp. - Internal URL for the API:
http://otp:8080(Coolify service name).
4. The backend service
Deploy backend/Dockerfile. It runs the API and the SPPO poller and
the alert evaluator in one process (vambora.main). Env vars (Coolify →
service → Environment; mark secrets as secret):
ENVIRONMENT=production
CORS_ALLOW_ORIGINS=https://vambora-web.pages.dev
DATABASE_URL=postgresql+asyncpg://postgres:<pw>@db:5432/vambora
DATABASE_URL_SYNC=postgresql+psycopg://postgres:<pw>@db:5432/vambora
REDIS_URL=redis://redis:6379/0
OTP_URL=http://otp:8080
SPPO_URL=https://dados.mobilidade.rio/gps/sppo
GTFS_URL=https://hub.tumidata.org/dataset/.../download/rdj.zip
GTFS_DATE_OVERRIDE=2024-05-08
HTTP_HOST=0.0.0.0
HTTP_PORT=8000
LOG_LEVEL=INFO
<pw> = the Postgres password from step 2 (secret). Keep
GTFS_DATE_OVERRIDE until a fresher GTFS feed is sourced (see plan.md "GTFS
Quirks"). Snapshot/ETA/alert tuning vars are optional (sane defaults).
CORS is the one thing that bites:
ENVIRONMENTmust be non-localandCORS_ALLOW_ORIGINSmust exactly equal the web origin (https://vambora-web.pages.dev, no trailing slash). Otherwise the web loads but every API call fails with a CORS error in the browser console.
5. TLS via sslip.io (no domain)
Set the backend service's public domain in Coolify to:
https://api-<VM_IP-with-dashes>.sslip.io
e.g. IP 203.0.113.7 → https://api-203-0-113-7.sslip.io. Coolify's proxy
requests a Let's Encrypt cert over HTTP-01 (this is why port 80 must be open
in both firewalls). Verify:
curl -s https://api-203-0-113-7.sslip.io/health
# {"ok":true}
If it hangs or cert issuance fails → port 80 blocked (recheck OCI security
list and iptables from the Oracle setup doc), or Let's Encrypt rate
limit (wait an hour; don't loop redeploys).
6. One-shot: migrate + load data
Open a terminal into the backend container (Coolify → service → Terminal):
alembic upgrade head
Then trigger the catalog import and the first offline snapshot (from your laptop or the VM):
curl -X POST https://api-203-0-113-7.sslip.io/admin/catalog/import # ~2 min, TUMI mirror is flaky (retries built in)
curl -X POST https://api-203-0-113-7.sslip.io/admin/snapshots/build
(/admin/* is unauthenticated for now — fine while the URL is obscure and
personal; gate it before any real public launch. Tracked as an open item.)
7. Web + docs on Cloudflare Pages
Method: Cloudflare dashboard Connect to Git — no API token, no DNS, no Zone (full rationale + the "what you won't see" gotchas: Running without a domain → Deploy to Cloudflare Pages).
Single monorepo vambora → two Pages projects on the same repo, each
with its own Root directory. Exact field-by-field setup +
@cloudflare/next-on-pages rationale + the "what you won't see" gotchas:
Running without a domain → Deploy to Cloudflare Pages.
-
dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git → authorise GitHub → repo
vambora(push it first — no commits yet). -
web: name
vambora-web, Root directoryweb, preset Next.js, buildnpx @cloudflare/next-on-pages@1, output.vercel/output/static(web/wrangler.tomlcarriesnodejs_compat). In Settings → Variables and Secrets (Production + Preview):NEXT_PUBLIC_API_BASE_URL = https://api-203-0-113-7.sslip.ioNEXT_PUBLIC_MAP_STYLE = https://api.maptiler.com/maps/dataviz-dark/style.json?key=YOUR_MAPTILER_KEYNODE_VERSION = 20Use your VM IP (dashed) and MapTiler key. The key lives only here and in local
web/.env.local— never committed. -
docs: name
vambora-docs, Root directorydocs, preset Docusaurus, buildpnpm build, outputbuild,NODE_VERSION=20, no app variables. -
Push to
main→ Cloudflare builds both →https://vambora-web.pages.devandhttps://vambora-docs.pages.dev(HTTPS automatic).
8. End-to-end smoke
Open https://vambora-web.pages.dev and confirm against the live API:
- Map loads with MapTiler tiles; ~hundreds–thousands of buses moving.
- Line search → a line detail page draws its route shape + live vehicles.
/plannerplans a trip (transfers + connection slack shown).- A stop page shows live ETAs ("Chegando agora / Ao vivo").
/offlinedownloads the bundle (version + counts shown).
If data is missing but the page renders: check the browser console for CORS
(step 4 note) and that /admin/catalog/import succeeded.
Troubleshooting quick table
| Symptom | Likely cause |
|---|---|
| Let's Encrypt never issues | Port 80 blocked in OCI security list or OS iptables |
| Web renders, API calls fail (console: CORS) | ENVIRONMENT=local or CORS_ALLOW_ORIGINS ≠ exact pages.dev origin |
| OTP unhealthy / OOM on first build | Lower -Xmx, ensure 24 GB shape, use --build --save once then --load (plan.md Appendix: OTP) |
/stops/*/arrivals empty | GTFS_DATE_OVERRIDE unset / outside the stale feed window (plan.md GTFS Quirks) |
| Instance won't create | A1 "out of host capacity" — retry / off-peak (Oracle setup doc) |
See plan.md "Appendix: OTP / Routing" for graph-build memory + --load
steady-state details.