From 93eb89d748d02fdc869d476195282246954d797e Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Wed, 5 Aug 2026 02:20:18 +1000 Subject: [PATCH 1/2] fix(deploy): make the Render blueprint actually deployable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blueprint could not have provisioned as written, and would have silently lost client IPs if it had. Blocking: the database specified `plan: starter`, which Render now classes as a LEGACY instance type that cannot be used for new databases. Switched to `basic-256mb` (the current flexible type). Blocking: the database had no `region`, so it defaulted independently of the web service. Render services reach a database over the private network only when they share a region — and since `ipAllowList: []` disables public ingress, a region mismatch leaves the app with no route to Postgres at all. Both are now pinned to oregon with a comment saying they must match. Silent: no TRUST_PROXY_HEADERS, the same defect just fixed for Fly. The image sets NODE_ENV=production, where mantis distrusts forwarding headers unless told otherwise, so every hit records ip = null. Also pins TRUSTED_IP_HEADER=x-forwarded-for, since Render does not strip an inbound CF-Connecting-IP and mantis tries that header first — without the pin a client can forge its recorded IP with one header. Also: - MANTIS_API_KEY_PEPPER moves from `sync: false` to `generateValue: true` — Render generates it once at create and never rotates it on later deploys, which matches the write-once requirement exactly and means the operator never has to handle the value - postgresMajorVersion 16 -> "17" (quoted per spec); the schema only needs gen_random_uuid(), core since 13 - documents that the first-boot log prints the admin key, and that the structured line above the banner carries only the 18-char prefix, so a naive grep for mantis_live_ yields a truncated key that 401s - optional retention/SMTP/BOOTSTRAP_API_KEY blocks, and a sharper free-tier warning: a tripwire is idle by definition, so spin-down means nearly every REAL hit pays the cold start Verified by booting the production image against Postgres 17.10 with exactly this env set: migrations applied, health db:ok, the bootstrap banner printed a working key (200 with it, 401 without), and a trigger carrying `X-Forwarded-For: 198.51.100.42` plus a forged `CF-Connecting-IP: 6.6.6.6` recorded 198.51.100.42. Co-Authored-By: Claude Opus 5 --- deploy/render.yaml.example | 113 +++++++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 22 deletions(-) diff --git a/deploy/render.yaml.example b/deploy/render.yaml.example index 3629674..dc94d07 100644 --- a/deploy/render.yaml.example +++ b/deploy/render.yaml.example @@ -1,20 +1,33 @@ # Render blueprint for mantis. +# # Usage: # 1. Fork or clone this repo to a GitHub account Render can read. # 2. cp deploy/render.yaml.example render.yaml (Render reads from the repo root) -# 3. Edit "name:" fields and any region you prefer. -# 4. Go to https://dashboard.render.com/blueprints and "New Blueprint" → point at the repo. -# 5. Render provisions both the web service and a Postgres database; it injects DATABASE_URL. -# 6. In the service settings, set the two required secrets that Render asks for: -# MANTIS_API_KEY_PEPPER — generate with `openssl rand -base64 32`. Mantis refuses to -# boot without it. Do NOT rotate after the first key is -# minted — rotating invalidates every API key. -# BOOTSTRAP_API_KEY — your initial admin API key, so you have something to log in -# with on first boot. +# 3. If you change the service `name:`, change PUBLIC_BASE_URL to match — +# Render serves the app at https://.onrender.com. +# 4. https://dashboard.render.com/blueprints → "New Blueprint" → point at the repo. +# 5. Render provisions the web service AND the Postgres, and wires DATABASE_URL +# to the database's INTERNAL (private-network) connection string. +# +# First login: mantis mints an admin API key on first boot and prints it to the +# log ONCE. Open the service's Logs tab and look for: +# ======================================== +# Mantis bootstrap API key (save this!) +# mantis_live_... +# Save it — it is both your dashboard login and your CLI token. +# +# Take the key from THAT banner, not from a bare search for "mantis_live_": a +# structured log line just above it carries only the key's 18-character prefix, +# so a naive grep hands you a truncated string that authenticates as 401. +# (To choose the key yourself instead, uncomment BOOTSTRAP_API_KEY below before +# the first deploy.) +# +# Cost at the plans below: ~$7/mo web service + ~$6/mo database. # -# Free-tier caveat: Render free web services spin down after 15 min of inactivity. -# That means the FIRST mantis trigger after idle will be slow (cold start, 30–60s). -# For real mantis use, upgrade the service to Starter ($7/mo) or self-host elsewhere. +# Free-tier caveat: free web services spin down after ~15 min idle, so the first +# canary trigger after a quiet period pays a 30–60s cold start. A tripwire is +# idle by definition, which means nearly every REAL hit would pay it — and that +# delays the alert you actually care about. Keep `starter` for real use. # # Public edge limits: # Render includes DDoS protection, but app-layer URL/rate abuse is still yours. @@ -26,28 +39,84 @@ services: name: mantis runtime: docker dockerfilePath: ./docker/Dockerfile - plan: starter # change to "free" if you accept cold-start latency - region: oregon + plan: starter # 512MB / 0.5 CPU. Measured peak use is ~150MB, so this fits + # with headroom. `free` spins down — see the caveat above. + region: oregon # MUST match the database region below, or the private + # connection string won't resolve and the app can't reach + # Postgres at all (ipAllowList below blocks the public path). healthCheckPath: /api/health # DB-aware: fails when Postgres is unreachable envVars: + # Change this whenever you change the service name above, and again if you + # attach a custom domain. Wrong value = broken canary URLs and wallet passes. - key: PUBLIC_BASE_URL - value: https://mantis.onrender.com # change to your mantis's URL after first deploy - # REQUIRED. The dashboard prompts for this value on first deploy. Generate with: - # openssl rand -base64 32 - # Mantis refuses to boot without it. Do NOT rotate after the first key is minted. + value: https://mantis.onrender.com + + # Server-side pepper for HMAC-hashing API keys at rest. Render generates a + # random 256-bit value once, on first create, and never rotates it on + # later deploys — which is exactly right, because rotating the pepper + # invalidates EVERY API key ever minted. You never need to see this value. + # If you ever recreate the service from scratch, copy the old value across + # first (Environment tab) or every existing key stops working. - key: MANTIS_API_KEY_PEPPER - sync: false + generateValue: true + + # REQUIRED ON RENDER. The image sets NODE_ENV=production, and in production + # mantis distrusts forwarding headers unless told otherwise — so without + # this EVERY hit records ip = null (verified against this image), which + # silently guts the point of a tripwire. The per-IP login limiter also + # fails open with no IP to bucket on. + - key: TRUST_PROXY_HEADERS + value: "1" + + # Pin IP attribution to the header Render's proxy sets. Render does not + # strip an inbound CF-Connecting-IP, and mantis tries that header first by + # default — so without this pin a client can forge its own recorded IP with + # one request header. If you later front this with a Cloudflare-proxied + # custom domain, change this to "cf-connecting-ip". + - key: TRUSTED_IP_HEADER + value: x-forwarded-for + - key: AUTO_MIGRATE value: "1" - key: LOG_LEVEL value: info + + # Internal (private-network) URL. External access is disabled entirely by + # ipAllowList below, so this is the only way in — which is the point. - key: DATABASE_URL fromDatabase: name: mantis-db property: connectionString + # Optional: pick your own first admin key instead of reading it from the + # first-boot log. Must look like mantis_live_<20+ chars>. `sync: false` + # makes Render prompt for it during blueprint creation. + # - key: BOOTSTRAP_API_KEY + # sync: false + + # Optional but recommended once you have real traffic: without these, + # hits and notifications are kept forever (the app warns on every boot), + # which grows the database unbounded and slows dashboard queries. + # - key: MANTIS_HIT_RETENTION_DAYS + # value: "90" + # - key: MANTIS_NOTIFICATION_RETENTION_DAYS + # value: "30" + + # Optional: email alerts. + # - key: SMTP_URL + # sync: false + # - key: SMTP_FROM + # value: Mantis + databases: - name: mantis-db - plan: starter # free tier expires after 90 days - postgresMajorVersion: 16 - ipAllowList: [] # block public ingress; only Render services in the same region can connect + # Flexible instance type. NOTE: `starter`/`standard`/`pro` are LEGACY names + # and Render will not create new databases on them. + plan: basic-256mb + region: oregon # must match the web service region above + postgresMajorVersion: "17" # schema only needs gen_random_uuid() (core since 13) + # Empty list = no public ingress at all. Render services in the SAME REGION + # still connect over the private network via the internal connection string + # above, so the database is never exposed to the internet. This is the main + # security win of running the app and database on one platform. + ipAllowList: [] From 86765b26b165b7392fcbec177898b98a3b3dcbf8 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:47:31 +1000 Subject: [PATCH 2/2] fix(deploy): harden Render blueprint for first deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a pre-deploy double-check against the current blueprint spec, on top of the earlier plan/region/proxy fixes: - diskSizeGB: 5 — omitting it gives Basic-tier databases a 15 GB disk by default, ~3x the storage cost for nothing on a canary DB. Disks can grow later but never shrink, so pin it small up front. - postgresMajorVersion "17" -> "18": parity with docker-compose, CI and the tier-2 suite, which all run postgres:18. Re-verified the image end-to-end against 18.4: migrations applied, health db:ok, bootstrap banner key authenticates (200/401), forged CF-Connecting-IP ignored. - Document that the .onrender.com namespace is global: plain "mantis" is taken, and when the name is taken Render silently serves at -.onrender.com. PUBLIC_BASE_URL then mints canary URLs pointing at ANOTHER customer's app — for a tripwire that means handed-out canary URLs trigger someone else's server. Usage now says pick a unique name, verify the served URL after first deploy, and fix PUBLIC_BASE_URL in this file (the blueprint is the source of truth; dashboard edits to synced vars are overwritten on the next sync). Co-Authored-By: Claude Fable 5 --- deploy/render.yaml.example | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/deploy/render.yaml.example b/deploy/render.yaml.example index dc94d07..c40a0f2 100644 --- a/deploy/render.yaml.example +++ b/deploy/render.yaml.example @@ -3,11 +3,17 @@ # Usage: # 1. Fork or clone this repo to a GitHub account Render can read. # 2. cp deploy/render.yaml.example render.yaml (Render reads from the repo root) -# 3. If you change the service `name:`, change PUBLIC_BASE_URL to match — -# Render serves the app at https://.onrender.com. +# 3. Change the service `name:` to something globally unique and set +# PUBLIC_BASE_URL to match. The .onrender.com namespace is shared by +# ALL Render customers — plain "mantis" is taken, and if your name is taken +# Render silently serves you at -.onrender.com instead. # 4. https://dashboard.render.com/blueprints → "New Blueprint" → point at the repo. # 5. Render provisions the web service AND the Postgres, and wires DATABASE_URL # to the database's INTERNAL (private-network) connection string. +# 6. AFTER the first deploy, compare the URL shown at the top of the service +# page with PUBLIC_BASE_URL. If they differ, fix PUBLIC_BASE_URL in this +# file and push — this file is the source of truth for synced env vars, so +# dashboard edits to it are overwritten on the next blueprint sync. # # First login: mantis mints an admin API key on first boot and prints it to the # log ONCE. Open the service's Logs tab and look for: @@ -22,7 +28,8 @@ # (To choose the key yourself instead, uncomment BOOTSTRAP_API_KEY below before # the first deploy.) # -# Cost at the plans below: ~$7/mo web service + ~$6/mo database. +# Cost at the plans below: ~$7/mo web service + ~$6/mo database + ~$1.50/mo +# storage (5 GB — see diskSizeGB). Check render.com/pricing for current rates. # # Free-tier caveat: free web services spin down after ~15 min idle, so the first # canary trigger after a quiet period pays a 30–60s cold start. A tripwire is @@ -46,10 +53,13 @@ services: # Postgres at all (ipAllowList below blocks the public path). healthCheckPath: /api/health # DB-aware: fails when Postgres is unreachable envVars: - # Change this whenever you change the service name above, and again if you - # attach a custom domain. Wrong value = broken canary URLs and wallet passes. + # MUST exactly match the URL Render actually serves you at (usage steps + # 3 and 6), and change it again if you attach a custom domain. Canary + # URLs and wallet passes are minted from this value — if it names an + # onrender.com subdomain you don't own, every canary URL you hand out + # points at ANOTHER Render customer's app and your triggers go to them. - key: PUBLIC_BASE_URL - value: https://mantis.onrender.com + value: https://mantis.onrender.com # <- change with the service name # Server-side pepper for HMAC-hashing API keys at rest. Render generates a # random 256-bit value once, on first create, and never rotates it on @@ -114,7 +124,13 @@ databases: # and Render will not create new databases on them. plan: basic-256mb region: oregon # must match the web service region above - postgresMajorVersion: "17" # schema only needs gen_random_uuid() (core since 13) + postgresMajorVersion: "18" # parity with docker-compose/CI (postgres:18); + # the schema itself only needs gen_random_uuid() + # (core since 13), so 17 works too if you prefer + # Without this, Basic-tier databases default to 15 GB (~3x the storage cost + # for nothing — 5 GB holds years of homelab canary hits). Must be 1 or a + # multiple of 5. You can grow a disk later, but never shrink it. + diskSizeGB: 5 # Empty list = no public ingress at all. Render services in the SAME REGION # still connect over the private network via the internal connection string # above, so the database is never exposed to the internet. This is the main