render.yaml
Rama master · réplica de lectura del repositorio original.
# Previews are opt-in: put "[render preview]" in a pull request's title to get one.
# Automatic previews were 60% of the September 2026 bill (~$105 of $179): 68 PRs,
# ~1900 preview-hours, each a full stack — web, worker, paid Key Value, Postgres —
# including archive and docs-only PRs nobody opened. A preview with no new commits
# for a day is deleted; push a commit to the PR to get a fresh one.
previews:
generation: manual
expireAfterDays: 1
databases:
- name: mapsurvey-db
# basic-1gb since 2026-09-28. On basic-256mb the kernel OOM-killed a Postgres
# backend four times (2026-09-15/16/17/21), each while a creator edited 2-5 MB
# reference layers: idle RSS ~100 MB, peaks 200-242 MB of 256. Every kill restarts
# all backends and 500s the in-flight requests. The plan lives HERE, not in the
# dashboard: a Blueprint sync overwrites a dashboard-only plan. Previews keep 256mb.
plan: basic-1gb
databaseName: mapsurvey
user: mapsurvey
postgresMajorVersion: 16
ipAllowList: []
previewPlan: basic-256mb
services:
- type: redis
name: mapsurvey-redis
plan: free
# Render allows one free Key Value instance per workspace and production
# holds it, so previews must use a paid plan (prorated for the hours the
# preview lives) or their whole sync fails on this resource.
previewPlan: starter
ipAllowList: []
- type: web
name: mapsurvey
runtime: docker
# Standard = 1 CPU / 2 GB. Starter (512 MB) was exceeded on 2026-09-15: the
# instance idles at 425-480 MB with two gthread workers, and one reference-layer
# upload or ZIP import (both run in this process and hold several copies of the
# parsed file) pushed it over the limit — eleven worker SIGKILLs, then three
# container restarts that 502'd a respondent's section submits. The plan lives
# HERE, not in the dashboard: a Blueprint sync overwrites a dashboard-only plan.
plan: standard
# Previews exist for review and are the box the k6 lecture-burst harness
# deliberately measures on; they do not need production's headroom.
previewPlan: starter
dockerfilePath: ./Dockerfile
dockerContext: .
# Migrations run here — after the new image is built, before the running instance is
# replaced — so schema changes land while the current version is still serving, and a
# failing migration aborts the deploy instead of taking the site down. The service
# mounts a disk, which rules out Render's zero-downtime deploy (a disk attaches to one
# instance at a time), so everything that can leave the start path has to.
#
# Only the web service gets this: the worker and the cron share the image and must not
# migrate.
#
# It has to be a script, not an inline command list: Render does NOT run this through a
# shell, so `migrate && createsuperuser || true` was parsed as arguments to migrate and
# the pre-deploy died with "unrecognized arguments: manage.py createsuperuser || true".
preDeployCommand: "sh ./predeploy.sh"
envVars:
# Secrets for PR previews come from `mapsurvey-shared`, an environment group
# created BY HAND in the Render dashboard and deliberately not declared in this
# Blueprint (an `envVarGroups` entry would be cloned, empty, for every preview).
# A `sync: false` variable is never copied into a preview, and the
# `previewEnvRenderServiceName` key that used to sit under them is not a
# Render field at all — so until this group, every preview ran without S3
# (web and worker on separate disks: ZIP import and image uploads "lost"
# the file), without mail (console backend), without a superuser and
# without a Mapbox token (PR #200, 2026-09-26).
# On production the service's own values win: Render guarantees a variable
# set on the service takes precedence over a linked group. Never put
# POSTHOG_* in the group — see the comments on those keys.
- fromGroup: mapsurvey-shared
- key: DATABASE_URL
fromDatabase:
name: mapsurvey-db
property: connectionString
- key: SECRET_KEY
generateValue: true
- key: DEBUG
value: "0"
- key: DJANGO_ALLOWED_HOSTS
value: "localhost mapsurvey.org mapsurvey.render.com *.render.com"
- key: MAPBOX_ACCESS_TOKEN
sync: false
# Server-only token for the printable form's Static Images calls. The public token
# above is URL-restricted, and server requests carry no Referer (issue #518).
- key: MAPBOX_SERVER_TOKEN
sync: false
# AI survey draft generation. The web service only needs the provider
# credentials to decide whether to render the AI panel; the worker below
# makes the actual calls. A provider with no key = feature simply off.
# AI_PROVIDER is declared (not left to the settings.py default) because
# the gate is per-provider: with 'anthropic' set and only a Gemini key
# present the panel would never render.
- key: AI_PROVIDER
value: gemini
- key: GEMINI_API_KEY
sync: false
- key: GEMINI_MODEL
sync: false
- key: ANTHROPIC_API_KEY
sync: false
# Off in production since 2026-08-17 evening, by product decision, after
# a day of measuring Gemini's SSE in anger: 47% of streamed calls failed
# (mid-answer cuts, stalls) in the morning batch, and one creator hit
# four consecutive failures — a dead end even through the retries. The
# blocking call has neither stream-specific failure mode, and at thinking
# `low` the wait is short enough for an indeterminate bar. Previews keep
# streaming on so the path stays exercised; reconsider against fresh
# $ai_generation error-rate data, not memory.
- key: AI_STREAMING_ENABLED
value: "false"
previewValue: "true"
# PayPro Global (billing app). The three keys are set on this service AND in
# the `mapsurvey-shared` env group, which is how previews get secrets (a
# `sync: false` value is never copied into a preview; see PR #201).
# PPG_PRODUCTS stays out of the group: unset on production means nothing
# is sold (the rollback switch); a preview under test gets it set on the
# preview service by hand. Previews accept TEST_MODE webhooks and build
# test-mode checkout links via previewValue; production never.
- key: PPG_VENDOR_ACCOUNT_ID
sync: false
- key: PPG_SECRET_KEY
sync: false
- key: PPG_VALIDATION_KEY
sync: false
- key: PPG_API_SECRET_KEY
sync: false
- key: PPG_PRODUCTS
sync: false
- key: PPG_ACCEPT_TEST_IPN
value: "false"
previewValue: "true"
- key: PPG_TEST_MODE
value: "false"
previewValue: "true"
- key: DEFAULT_FROM_EMAIL
sync: false
- key: DEMO_SURVEY_URL
sync: false
- key: DJANGO_SUPERUSER_USERNAME
sync: false
- key: DJANGO_SUPERUSER_EMAIL
sync: false
- key: DJANGO_SUPERUSER_PASSWORD
sync: false
- key: EMAIL_BACKEND
sync: false
- key: EMAIL_HOST
sync: false
- key: EMAIL_PORT
sync: false
- key: EMAIL_USE_SSL
sync: false
- key: EMAIL_HOST_USER
sync: false
- key: EMAIL_HOST_PASSWORD
sync: false
- key: CELERY_BROKER_URL
fromService:
type: redis
name: mapsurvey-redis
property: connectionString
- key: CELERY_RESULT_BACKEND
fromService:
type: redis
name: mapsurvey-redis
property: connectionString
- key: REDIS_URL
fromService:
type: redis
name: mapsurvey-redis
property: connectionString
- key: CLOUDFLARE_TRUSTED
value: "True"
# Attempts that pass form validation.
- key: REGISTRATION_RATE_LIMIT_HOUR
value: "3"
- key: REGISTRATION_RATE_LIMIT_DAY
value: "10"
# Attempts rejected by form validation — a separate, far looser budget so
# someone fighting the password field is never locked out. Nobody
# legitimate should reach these; watch AbuseEvent(detail='invalid_hour').
- key: REGISTRATION_INVALID_LIMIT_HOUR
value: "15"
- key: REGISTRATION_INVALID_LIMIT_DAY
value: "50"
# Kill switch for the split above. Set to "False" from the dashboard to
# restore the previous single-counter behaviour without a rebuild.
- key: REGISTRATION_SPLIT_RATE_LIMIT
value: "True"
- key: SITE_URL
value: "https://mapsurvey.org"
- key: NEWSLETTER_SITE_URL
value: "https://mapsurvey.org"
# Gunicorn concurrency — tunable from the dashboard without an image rebuild.
# Slots = WEB_CONCURRENCY × GUNICORN_THREADS (gthread). Measured 2026-09-15 on
# Starter: 425-480 MB idle with two workers (the "~110 MB per worker" this was
# sized on predates reference layers), so two workers need the 2 GB plan above.
- key: WEB_CONCURRENCY
value: "2"
- key: GUNICORN_THREADS
value: "4"
- key: GUNICORN_TIMEOUT
value: "60"
# Worker recycling (change layer-memory-diet). A multi-megabyte GeoJSON parsed
# into Python objects fragments the allocator arenas, and a worker that never
# exits keeps that RSS for life: one 8.6 MB layer upload measured +107 MB
# retained after everything was freed. Recycling after ~300 requests (boot is
# under a second) bounds the high-water mark in time; jitter keeps the two
# workers from restarting together.
#
# The worker class matters: stock gthread drops the connections it accepted
# in its last loop iteration when it recycles or gets a deploy's SIGTERM —
# one 502 per recycle on 2026-09-16. mapsurvey.gunicorn_workers.
# DrainingThreadWorker stops accepting first and serves what it holds
# (scripts/gunicorn_recycle_check.py is the reproduction). Setting this back
# to "gthread" from the dashboard is the rollback; set GUNICORN_MAX_REQUESTS
# to "0" to stop recycling altogether.
- key: GUNICORN_WORKER_CLASS
value: "mapsurvey.gunicorn_workers.DrainingThreadWorker"
- key: GUNICORN_MAX_REQUESTS
value: "300"
- key: GUNICORN_MAX_REQUESTS_JITTER
value: "60"
# Internal product analytics. Deliberately NOT in the mapsurvey-shared group:
# inheriting the production key would make every PR preview write test traffic
# into the real project. Previews leave it unset, and unset renders no snippet.
- key: POSTHOG_PROJECT_KEY
sync: false
- key: POSTHOG_API_HOST
sync: false
# First-party host for browser events only (managed reverse proxy). Unset falls
# back to POSTHOG_API_HOST, so a preview that never got the CNAME behaves as before.
- key: POSTHOG_CLIENT_HOST
sync: false
# Session replay of the editor. Unset = off, which is what previews get: they
# never carry a real key, and a recording posture should not be inherited.
- key: POSTHOG_SESSION_REPLAY
sync: false
# Support chat (Chatwoot Cloud). Both values also go to `mapsurvey-shared` so a
# preview can be tested end to end; unset = no widget. The HMAC token is a secret:
# it signs creators' identities, and with it anyone could chat as any creator.
- key: CHATWOOT_WEBSITE_TOKEN
sync: false
- key: CHATWOOT_HMAC_TOKEN
sync: false
# Media object storage. Unset USE_S3 = uploads go to the disk below, which
# is how this ships: the switch is flipped from the dashboard only after
# the existing files have been copied into the bucket and verified.
#
# Previews get the bucket and credentials from mapsurvey-shared but NOT the key prefix —
# MEDIA_S3_NAMESPACE is deliberately absent here, so settings.py derives
# previews/<service> from Render's own IS_PULL_REQUEST/RENDER_SERVICE_NAME.
# A preview therefore cannot address, overwrite or delete a production key.
- key: USE_S3
sync: false
- key: AWS_STORAGE_BUCKET_NAME
sync: false
- key: AWS_S3_REGION_NAME
value: ap-southeast-2
# Credentials belong to a bucket-scoped IAM user and are secrets: this
# repository is public, so they are never literals in the Blueprint.
- key: AWS_ACCESS_KEY_ID
sync: false
- key: AWS_SECRET_ACCESS_KEY
sync: false
# No disk, on purpose — this is what buys zero-downtime deploys. Media lives
# in S3 (USE_S3 above); the old media-storage disk was copied there and
# verified object-for-object on 2026-08-27 before this block was removed.
# With no disk attached, Render starts the new instance alongside the old
# one and switches traffic only after the health check passes, so deploys
# stop dropping requests and the service is free to scale past one instance.
# Do not reintroduce a disk here without understanding that it brings the
# deploy 502 window back with it.
healthCheckPath: /
- type: worker
name: mapsurvey-celery
runtime: docker
plan: starter
# Previews DO get a worker: AI draft generation runs here, and a preview
# without it would leave the feature untestable in the one environment
# where changes are reviewed before merge. The metrics cron stays off —
# nothing on a preview reads its numbers.
dockerfilePath: ./Dockerfile
dockerContext: .
# `-B` embeds Celery beat (change survey-schedule, #497): the schedule that closes
# surveys at their closing time. Safe ONLY while this service runs one instance --
# two workers with `-B` fire every beat twice. Scaling the worker means moving beat
# into a service of its own (`celery -A mapsurvey beat`). The schedule file lives in
# /tmp: the container is ephemeral and beat recomputes it from settings on start.
# `-Q celery,styles`: map style publishing (change map-styles-library, #542) rides its
# own queue, rate-limited under the Styles API's 50 writes/min; a worker that does not
# consume it leaves every new style "Publishing…" forever.
dockerCommand: celery -A mapsurvey worker -Q celery,styles -B -s /tmp/celerybeat-schedule -l info --concurrency 2
envVars:
# Same group as the web service: the worker reads the files the web stores.
# SECRET_KEY comes from the group too, on purpose (#388): a generated value
# here gave the worker a key of its own, and every link it signed (the
# abuse-review notice) failed `BadSignature` on the web service. Blueprints
# cannot reference another service's variable, so the group holds the web
# service's value, pasted by hand; the web keeps its own generated key,
# which wins over the group on that service.
- fromGroup: mapsurvey-shared
- key: DATABASE_URL
fromDatabase:
name: mapsurvey-db
property: connectionString
- key: CELERY_BROKER_URL
fromService:
type: redis
name: mapsurvey-redis
property: connectionString
- key: CELERY_RESULT_BACKEND
fromService:
type: redis
name: mapsurvey-redis
property: connectionString
# Error tracking: the task_failure receiver in mapsurvey/celery.py reports
# failed tasks. Same no-preview-inheritance rule as on the web service.
- key: POSTHOG_PROJECT_KEY
sync: false
- key: POSTHOG_API_HOST
sync: false
# Renders no templates, so this changes nothing here. Declared anyway so the two
# services cannot drift into different PostHog configurations.
- key: POSTHOG_CLIENT_HOST
sync: false
# Map style publishing (change map-styles-library, #542): the SECRET Mapbox token
# (styles:write, styles:list) lives on the worker only — the web service never
# gets it, so no template can leak it. The account is the one the styles live in.
- key: MAPBOX_STYLES_TOKEN
sync: false
- key: MAPBOX_ACCOUNT
sync: false
- key: DEFAULT_FROM_EMAIL
sync: false
- key: EMAIL_BACKEND
sync: false
- key: EMAIL_HOST
sync: false
- key: EMAIL_PORT
sync: false
- key: EMAIL_USE_SSL
sync: false
- key: EMAIL_HOST_USER
sync: false
- key: EMAIL_HOST_PASSWORD
sync: false
- key: SITE_URL
value: "https://mapsurvey.org"
- key: NEWSLETTER_SITE_URL
value: "https://mapsurvey.org"
# Media object storage. The worker writes files too — ZIP import and AI
# draft generation both save images — so it must resolve storage exactly
# like the web service, or an import would write to the disk while the web
# service reads from the bucket. Same rule on the namespace: absent here,
# derived per environment, so a preview worker cannot touch production keys.
- key: USE_S3
sync: false
- key: AWS_STORAGE_BUCKET_NAME
sync: false
- key: AWS_S3_REGION_NAME
value: ap-southeast-2
- key: AWS_ACCESS_KEY_ID
sync: false
- key: AWS_SECRET_ACCESS_KEY
sync: false
# AI survey draft generation runs here — this is the service that talks
# to the model provider. Keep AI_PROVIDER identical to the web service:
# the web side gates the panel on it, the worker resolves the client from
# it, and a mismatch shows a panel whose every generation then fails.
- key: AI_PROVIDER
value: gemini
- key: GEMINI_API_KEY
sync: false
- key: GEMINI_MODEL
sync: false
- key: ANTHROPIC_API_KEY
sync: false
# Off in production since 2026-08-17 evening, by product decision, after
# a day of measuring Gemini's SSE in anger: 47% of streamed calls failed
# (mid-answer cuts, stalls) in the morning batch, and one creator hit
# four consecutive failures — a dead end even through the retries. The
# blocking call has neither stream-specific failure mode, and at thinking
# `low` the wait is short enough for an indeterminate bar. Previews keep
# streaming on so the path stays exercised; reconsider against fresh
# $ai_generation error-rate data, not memory.
- key: AI_STREAMING_ENABLED
value: "false"
previewValue: "true"
# Reclaims media left behind by destroyed PR previews. Render has no teardown
# hook, so this reconciles: it lists the previews/<service>/ prefixes in the
# bucket, asks Render which services still exist, and deletes only the orphans.
# Converges within a day of a pull request closing.
#
# An unreadable service listing deletes nothing — a partial answer would make
# live previews look dead. Without RENDER_API_KEY the job is a no-op, so an
# unconfigured token means objects accumulate, never that they are lost.
- type: cron
name: mapsurvey-preview-media-reclaim
runtime: docker
plan: starter
previews:
generation: off
schedule: "30 4 * * *"
dockerfilePath: ./Dockerfile
dockerContext: .
dockerCommand: sh ./reclaim.sh
envVars:
- key: DATABASE_URL
fromDatabase:
name: mapsurvey-db
property: connectionString
- key: SECRET_KEY
generateValue: true
# Reads and deletes objects, so it needs the bucket — but it must resolve
# production's own prefixes, never a preview namespace.
- key: USE_S3
sync: false
- key: AWS_STORAGE_BUCKET_NAME
sync: false
- key: AWS_S3_REGION_NAME
value: ap-southeast-2
- key: AWS_ACCESS_KEY_ID
sync: false
- key: AWS_SECRET_ACCESS_KEY
sync: false
- key: MEDIA_S3_NAMESPACE
value: ""
# Read-only Render API token, used solely to ask which services exist.
- key: RENDER_API_KEY
sync: false
# sync_posthog_person_properties runs last in reclaim.sh. Same values as the
# web service; without the key that step fails after the others finished.
- key: POSTHOG_PROJECT_KEY
sync: false
- key: POSTHOG_API_HOST
sync: false
# notify_lapsed (billing app) runs here too and mails owners whose Pro
# ended; without these the cron would fall back to the console backend.
- key: EMAIL_BACKEND
sync: false
- key: EMAIL_HOST
sync: false
- key: EMAIL_PORT
sync: false
- key: EMAIL_USE_SSL
sync: false
- key: EMAIL_HOST_USER
sync: false
- key: EMAIL_HOST_PASSWORD
sync: false
- key: DEFAULT_FROM_EMAIL
sync: false
- key: SITE_URL
value: "https://mapsurvey.org"
# Help centre — help.mapsurvey.org (openspec: help-site, #298). A Starlight site built
# from `help/`, nothing else in the repository. No previews, by owner decision
# (2026-10-13): content is reviewed with `npm run dev` locally and goes straight to
# production on merge, like the rest of the Blueprint. `buildFilter` keeps Django-only
# commits from rebuilding it and help-only commits from touching the web service. Should
# a preview ever be built, Render's IS_PULL_REQUEST=true makes every page noindex
# (help/astro.config.mjs). PostHog is injected only when PUBLIC_POSTHOG_KEY is set.
- type: web
name: mapsurvey-help
runtime: static
rootDir: help
buildCommand: npm ci && npm run build
staticPublishPath: dist
buildFilter:
paths:
- help/**
previews:
generation: off
domains:
- help.mapsurvey.org
envVars:
- key: NODE_VERSION
value: "22.23.4"
- key: PUBLIC_POSTHOG_KEY
sync: false
- key: PUBLIC_POSTHOG_HOST
sync: false
headers:
- path: /*
name: X-Content-Type-Options
value: nosniff
- path: /*
name: Referrer-Policy
value: strict-origin-when-cross-origin