# Production stack — the live storefront. See issue #118 for why this file is # in the repository at all. # # It used to live only in Portainer's web editor, which meant no test could read # it. `backend/tests/unit/composeEnvironment.test.ts` asserts that the deploying # environment sets every name in ALWAYS_REQUIRED, and it could only ever check # QA. Production was unguarded, and on 2026-08-23 it refused to boot because # UPLOADS_DIR had no line here — while being set in Portainer's stack variables, # where it does nothing. Committing the file is what lets the guard cover it. # # Name the Portainer stack `redefined-designs`, NOT `redefined-designs-qa`. # The stack name becomes the compose project name, and reusing QA's would make # compose reconcile the two against each other. # # DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor — otherwise the # file here and the file that actually runs drift apart again, which is the # whole problem this is solving. # # Repository: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs # Reference: refs/heads/main # Compose path: docker-compose.prod.yml # # `pull_policy: build` matters. Without it the stack reuses whatever is already # tagged redefined-designs:latest, which is how a redeploy can appear to succeed # while still running old code. Leave any Portainer option that re-pulls images # turned OFF — there is no registry to pull this image from. # # WHY MOST VALUES ARE HARDCODED HERE RATHER THAN INTERPOLATED # # Portainer's stack variables are substituted into this file; they are not # handed to the container. A variable set in Portainer with no line here never # reaches the app, and the failure reads as "I set it and it says it is not # set". Only secrets are interpolated below, because only secrets have a reason # not to be in the repository. Everything else is written out, so there is one # place to look and one thing that can be wrong. # # Required stack environment variables — all secrets, all must be set in # Portainer for this stack: # # DB_PASSWORD Postgres password for the `redefined` database. # SMTP_USER Brevo SMTP login. # SMTP_PASSWORD # SMTP_FROM The From address customers see. # ADMIN_GATE_SECRET The shared secret Nginx Proxy Manager injects as the # X-Admin-Gate header on the gated location. Both sides # must hold the same value or the admin API returns 403. # See #63. Without it, /api/admin is protected only by # the proxy — anything reaching the container directly # can administer the store. # PAYPAL_CLIENT_ID Live PayPal credentials. Required because DEMO_MODE is # PAYPAL_CLIENT_SECRET false below; the app refuses to start without them. # PAYPAL_WEBHOOK_ID # USPS_CLIENT_ID Optional. Leave unset to run without address # USPS_CLIENT_SECRET validation; the app degrades gracefully rather than # failing, so an empty value is a working configuration. # # If the existing stack in Portainer uses different names for any of these, # rename them there to match — the names above are what this file reads. services: redefined-designs: # Built from this repository by Portainer rather than pulled. The same # Dockerfile QA uses, so the two images differ only in configuration. build: context: . dockerfile: Dockerfile image: redefined-designs:latest # Always build; never reuse the existing tag. pull_policy: build container_name: redefined-designs-syn environment: - TZ=America/Chicago - PORT=3000 # NODE_ENV is deliberately absent. The Dockerfile sets it to `production`, # and that value gates the `secure` flag on the session cookie. Setting it # to anything else here would silently serve session cookies over plain # HTTP. Do not add a line for it. - PGHOST=redefined-designs-db-syn - PGPORT=5432 - PGUSER=redefined - PGPASSWORD=${DB_PASSWORD} - PGDATABASE=redefined # Real payments. This is the difference between production and QA, and it # is why the three PayPal secrets are required rather than optional — the # app refuses to start without them when this is false. # # To bring the stack up before PayPal is configured, set this to `true` # and the three PAYPAL_ lines can be removed. The full cart and checkout # flow then works end to end and NOBODY IS EVER CHARGED. That is a # deliberate interim state and a quiet disaster if it is left on. - DEMO_MODE=false - PAYPAL_ENV=live - PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID} - PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET} - PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID} # Host, port and secure are not secrets and are pinned rather than # inherited: the mailer's fallbacks are Gmail's (smtp.gmail.com, 465, TLS) # and Brevo needs 587 with STARTTLS, which is why SMTP_SECURE is false. # Getting these wrong fails at send time, not at boot. - SMTP_HOST=smtp-relay.brevo.com - SMTP_PORT=587 - SMTP_SECURE=false - SMTP_USER=${SMTP_USER} - SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_FROM=${SMTP_FROM} # MAIL_ALLOWLIST is deliberately absent, and this is the one environment # where that is correct. It restricts delivery to named recipients, which # is what keeps QA from emailing real customers. Production has to be able # to reach real customers, so it is unrestricted here on purpose. The # boot-time warning about it is expected and should not be silenced. # Not a secret, and hardcoded rather than interpolated so it cannot go # missing: every link in a verification, password-reset, favorite-alert # and cart-reminder email is built from it, and an unset value renders # them all as "undefined". - PUBLIC_URL=https://redefined-designs.bermudalamb.synology.me - SITE_CURRENCY=USD # Must match the right-hand side of the volume mapping below. Hardcoded # for that reason — splitting it across two places is how they drift, and # its absence is what stopped this stack booting on 2026-08-23. - UPLOADS_DIR=/app/uploads # Optional. Address validation is skipped when these are empty, rather # than failing, so an unset pair is a working configuration. - USPS_ENV=production - USPS_CLIENT_ID=${USPS_CLIENT_ID} - USPS_CLIENT_SECRET=${USPS_CLIENT_SECRET} - ADMIN_GATE_SECRET=${ADMIN_GATE_SECRET} volumes: # Production's own uploads directory. QA writes to # /volume1/configs/redefined-designs-qa/uploads; sharing this one would # let a QA teardown delete real product images. - /volume1/configs/redefined-designs/uploads:/app/uploads ports: # 32750, not QA's 32751. # # An unqualified host port binds to 0.0.0.0, so this answers directly on # http://:32750 from anywhere on the LAN, bypassing Nginx Proxy # Manager and its TLS. The admin gate still fails closed — a direct # request arrives without the X-Admin-Gate header — but the storefront, # the customer API, login and registration are all reachable in the clear. # # That is #117, and the fix is not a loopback binding: NPM runs as its own # container, so 127.0.0.1 would stop the proxy reaching this at all. The # fix is a shared external network with this block removed entirely, which # also requires repointing the proxy host entry at the container name and # port 3000. Left as-is here so this file matches what is deployed today; # changing it is #117's job, not this file's. - 32750:3000 depends_on: redefined-designs-db-syn: condition: service_healthy # Unlike QA's `no`: production is meant to come back after a NAS reboot. restart: unless-stopped # Docker's default json-file driver has no size cap. POST /api/client-errors # is unauthenticated, so an unrotated log is a disk-filling vector on its # own. QA has carried these options for a while; production could not, # because this file did not exist. logging: driver: json-file options: max-size: 10m max-file: "3" redefined-designs-db-syn: image: postgres:16 container_name: redefined-designs-db-syn environment: - POSTGRES_USER=redefined - POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_DB=redefined - PGDATA=/var/lib/postgresql/data/pgdata volumes: # THE REAL DATA. Distinct from QA's # /volume1/configs/redefined-designs-qa/postgres. Never point a QA stack # at this path. - /volume1/configs/redefined-designs/postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U redefined -d redefined"] interval: 10s timeout: 5s retries: 10 restart: unless-stopped logging: driver: json-file options: max-size: 10m max-file: "3"