Ctrl K
ComunidadGuías…

render.yaml

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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