# 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/ 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// 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