Skip to main content

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:

ServiceImageNotes
dbtimescale/timescaledb-ha:pg16env POSTGRES_USER=postgres, POSTGRES_PASSWORD=<generate>, POSTGRES_DB=vambora; persistent volume on /home/postgres/pgdata; do not publish a public port
redisredis:7-alpineno public port
otpopentripplanner/opentripplanner:2.5.0see 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/opentripplanner containing rio.osm.pbf, rio-gtfs.zip, and build-config.json (copy these up: scp -r backend/otp vambora-vps:/srv/vambora-otp then mount that path).
  • First run: command ["--build", "--save", "--serve"], env JAVA_TOOL_OPTIONS=-Xmx12g. It writes graph.obj into 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: ENVIRONMENT must be non-local and CORS_ALLOW_ORIGINS must 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.7https://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 vamboratwo 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.

  1. dash.cloudflare.com → Workers & Pages → Create → Pages → Connect to Git → authorise GitHub → repo vambora (push it first — no commits yet).

  2. web: name vambora-web, Root directory web, preset Next.js, build npx @cloudflare/next-on-pages@1, output .vercel/output/static (web/wrangler.toml carries nodejs_compat). In Settings → Variables and Secrets (Production + Preview):

    NEXT_PUBLIC_API_BASE_URL = https://api-203-0-113-7.sslip.io
    NEXT_PUBLIC_MAP_STYLE = https://api.maptiler.com/maps/dataviz-dark/style.json?key=YOUR_MAPTILER_KEY
    NODE_VERSION = 20

    Use your VM IP (dashed) and MapTiler key. The key lives only here and in local web/.env.local — never committed.

  3. docs: name vambora-docs, Root directory docs, preset Docusaurus, build pnpm build, output build, NODE_VERSION=20, no app variables.

  4. Push to main → Cloudflare builds both → https://vambora-web.pages.dev and https://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.
  • /planner plans a trip (transfers + connection slack shown).
  • A stop page shows live ETAs ("Chegando agora / Ao vivo").
  • /offline downloads 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

SymptomLikely cause
Let's Encrypt never issuesPort 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 buildLower -Xmx, ensure 24 GB shape, use --build --save once then --load (plan.md Appendix: OTP)
/stops/*/arrivals emptyGTFS_DATE_OVERRIDE unset / outside the stale feed window (plan.md GTFS Quirks)
Instance won't createA1 "out of host capacity" — retry / off-peak (Oracle setup doc)

See plan.md "Appendix: OTP / Routing" for graph-build memory + --load steady-state details.