# 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. # # 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. # # Migrating production onto it is a one-time procedure with an order that # matters, and it is written down rather than restated here: # # docs/ops/production-stack-cutover.md # # Routine deploys after that are README.md, "Promoting a reviewed change to # production". The two are different operations and the runbook says which is # which. # # THIS STACK DOES NOT BUILD. It runs the image tagged redefined-designs:latest, # which has to already exist on the NAS before the stack starts. A first deploy, # or a NAS that has pruned images, fails with "image not found" rather than # quietly building one — see #146. # # That is deliberate. QA builds from this repository and is reviewed; production # then runs the image that was reviewed, promoted by hand: # # docker tag redefined-designs:qa redefined-designs:latest # # Building here instead would look simpler and would ship something else. The # Dockerfile copies package.json without package-lock.json and installs with # `npm install`, so two builds of the same commit can resolve different # transitive dependencies. "Same git ref" is therefore not "same image", and the # reviewed bytes are the only thing that is. # # The promotion is load-bearing, not a convenience. Forgetting it means a # redeploy that reuses the previous redefined-designs:latest and appears to # succeed while running old code — the same failure `pull_policy: build` guards # against in QA, arriving by a different route. State which image is being # promoted as part of the deploy. # # 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". Secrets are interpolated below, because they have a reason not to be in # the repository — and so is DEMO_MODE, which is not a secret but is the one # value that gets flipped without a code change, which is a reason of its own # (#190). Everything else is written out, so there is one place to look and one # thing that can be wrong. # # The stack environment variables this file reads. Each entry says whether it is # required and when — there is no blanket rule, because three are unused while # DEMO_MODE is `true`, three are optional, and DEMO_MODE and SMTP_FROM are not # secrets at all: # # DEMO_MODE `true` or `false`, exactly. Whether real payments are # taken. Not a secret — it is here rather than written # into the file below because it is the one value that # gets flipped without a code change. There is no # default: unset or mistyped refuses to boot rather than # choosing for you. # 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 when DEMO_MODE is # PAYPAL_CLIENT_SECRET `false`; the app refuses to start without them rather # PAYPAL_WEBHOOK_ID than pretending it can take payments. Unused while # DEMO_MODE is `true`, so flipping it to `false` without # them crash-loops the container. # BACKUP_PASSPHRASE Optional. Set it and the uploads archives are # encrypted at rest; leave it empty and they are not. # See docs/ops/backup-and-restore.md before setting it — # an archive nobody can decrypt is not a backup. # 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. # ANTHROPIC_API_KEY Optional. Drafts a listing from a submitted photo # (#223). Unset means submissions still arrive and wait # undrafted, which is a working configuration for the # same reason USPS is. The one credential here that # spends money per call, and on a path anybody holding # an upload link can trigger — put a spend limit on the # key in the Anthropic console, because nothing in this # repository can enforce one. # ANTHROPIC_WORKSPACE_ID Required alongside the key above when that key is # identity-linked. Without it every draft fails with a # 400 naming the missing header (#271). # INTAKE_ACTION_SECRET Optional. Signs the regenerate and discard links in the # intake notification email (#224). Absent, the email # still sends and carries no shortcuts. # GOOGLE_CLIENT_ID Optional, and all-or-nothing with the secret below — # GOOGLE_CLIENT_SECRET setting one without the other refuses to boot (#340). # Both unset means Google sign-in is simply not offered. # The redirect URI is derived from PUBLIC_URL and must # match what is registered in the Google Auth Platform # exactly, so changing the domain means updating that # console too (#313). # REMBG_URL Optional. The background-removal sidecar, e.g. # http://rembg-syn:7000. Unset turns the feature off. # # The names above are what this file reads. A stack variable under any other # name is substituted nowhere and never reaches the container, so reconciling # them is a step of its own in the cutover runbook. services: redefined-designs: # No `build:` — see the note at the top of this file. This tag is produced # by promoting the image QA was reviewed against, not by rebuilding here. image: redefined-designs:latest 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 # =================================================================== # PRODUCTION IS IN DEMO MODE. IT IS TAKING NO MONEY. # # The whole cart and checkout flow works end to end and NOBODY IS EVER # CHARGED. This is the deliberate interim the note below describes, taken # on 2026-08-25 to bring the stack up during the cutover to this file # before the live PayPal credentials were to hand. # # Restoring it is #190: fill the three PayPal stack variables and set the # DEMO_MODE stack variable to `false`. Both are Portainer values now, so # restoring real payments needs no commit — which is the point, and also # why this banner and the guard test exist to keep the state visible. # Until then every order placed is a pretend one. # =================================================================== # # 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. # # Supplied by the stack, and with NO DEFAULT, deliberately. # `${DEMO_MODE:-false}` is the obvious thing to write and the wrong thing: # a default decides whether the shop takes money on the operator's behalf, # silently, whichever way it points. # # Having no default is safe rather than fragile, because `checkDemoMode` # is strict. An unset stack variable substitutes to an empty string, and # anything that is not exactly `true` or `false` refuses to boot naming # DEMO_MODE. So a missing or mistyped value fails loudly at startup # instead of guessing — which is what makes interpolating this one safe. - DEMO_MODE=${DEMO_MODE} - 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} # The origin uploaded images are fetched from (#103). Empty means this # application serves them from its own origin, which works and is what # every environment does today — so this is safe to leave unset while the # hostname below does not exist yet. # # Present as a line even while empty, deliberately: a Portainer stack # variable with no line here is substituted into this file and never # reaches the container, which is exactly how UPLOADS_DIR went missing. # # Setting it needs an Nginx Proxy Manager host for the name, pointing at # this same container, and a certificate that covers it. Until then the # server warns at boot that the defence is off rather than staying silent. - UPLOADS_BASE_URL=${UPLOADS_BASE_URL:-} # Drafts a listing from a submitted photo (#223). # # Absent is a working configuration, deliberately. A submission still # arrives, keeps its photos and waits in the queue undrafted — losing # somebody's consignment to an expired key would be far worse than an # item arriving without its description written, and the photos may be # the only copy of an item no longer in the sender's hands. # # The one credential in this stack that spends money per call, on a path # anybody holding an upload link can trigger. Set a spend limit on the # key in the Anthropic console — nothing in this repository can enforce # one, and #227 is the submission ceiling that bounds the volume rather # than the bill. - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} # Required alongside the key when that key is identity-linked. Such a key # is refused with a 400 unless the request names its workspace, and a # key's shape does not say which kind it is (#271). Optional: ordinary # keys need no workspace. - ANTHROPIC_WORKSPACE_ID=${ANTHROPIC_WORKSPACE_ID:-} # Optional. The background-removal sidecar (#281). # See docs/ops/image-background-removal-stack.md. - REMBG_URL=${REMBG_URL:-} # Brevo's Marketing Automation key (#56), from # https://app.brevo.com/automation/parameters. Optional: unset means the # tracker is never loaded and no browsing is reported to anyone. # # Not a secret — it ships to the browser by design — but it is # per-environment, and this is the only place production's is named. A # key here is still not sufficient to track anybody: the script loads # only for a signed-in customer whose stored consent wording covers # analytics. See the note on MARKETING_CONSENT_TEXT in backend/src/utils.ts. - BREVO_TRACKER_KEY=${BREVO_TRACKER_KEY:-} # Signs the regenerate and discard links in the intake notification email # (#224). Optional: absent, the notification still sends and links to the # review queue without shortcuts. Rotating it revokes every outstanding # link, which is how a leaked one is dealt with. - INTAKE_ACTION_SECRET=${INTAKE_ACTION_SECRET:-} # Both or neither. envValidation refuses to start on one without the # other, because the failure would otherwise arrive at the moment a # customer presses the button (#340). - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_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" # --------------------------------------------------------------------------- # Backups (#147) # # Two services rather than one, because they are different jobs on different # cadences. The database is small, changes constantly, and wants a logical # dump. Uploads are large, append-mostly, and want an archive. Forcing both # through one tool serves one of them badly. # # WHAT THESE DO NOT COVER, and it matters: # # They run while the stack runs, so they cannot protect the stack's own # teardown. Deleting the Portainer stack deletes these containers along with # everything else. The manual pg_dump in README's deploy steps therefore # stays exactly where it is — a routine regime and a snapshot taken before a # risky operation are different jobs, and neither replaces the other. # # And they write to the same volume as the data they protect. That survives a # bad migration, a dropped table, a bad deploy and a stack deletion. It does # not survive the disk. Getting a copy off /volume1 is a Synology-side job — # Hyper Backup to another volume, an external disk, or offsite — and it is # what turns this from a convenience into a guarantee. See # docs/ops/backup-and-restore.md. # --------------------------------------------------------------------------- redefined-designs-db-backup-syn: # Pinned to 16 to match the server. pg_dump refuses to dump a server newer # than itself, so a floating tag here is a backup that stops working on the # day Postgres is upgraded — silently, since nothing reads the dumps until # they are needed. image: prodrigestivill/postgres-backup-local:16 container_name: redefined-designs-db-backup-syn environment: - TZ=America/Chicago - POSTGRES_HOST=redefined-designs-db-syn - POSTGRES_PORT=5432 - POSTGRES_DB=redefined - POSTGRES_USER=redefined - POSTGRES_PASSWORD=${DB_PASSWORD} # Daily at 03:00. Late enough that a deploy is unlikely to be in flight, # and pg_dump takes a consistent snapshot anyway, so a dump running while # customers are shopping is fine. - SCHEDULE=@daily - BACKUP_KEEP_DAYS=7 - BACKUP_KEEP_WEEKS=4 - BACKUP_KEEP_MONTHS=6 # --clean --if-exists so the dump can be restored over an existing # database without hand-dropping it first, which is the state a real # restore happens in. - POSTGRES_EXTRA_OPTS=--clean --if-exists volumes: - /volume1/configs/redefined-designs/backups/postgres:/backups depends_on: redefined-designs-db-syn: # The dumper is a client and needs a server accepting connections. This # is the constraint that shaped the design: without it the first run # after a NAS reboot races Postgres coming up. condition: service_healthy healthcheck: # Unhealthy when nothing has been written inside the window. A backup # regime that stopped a month ago is indistinguishable from a working one # until a restore is attempted, and this is the cheapest thing that tells # them apart. It shows in Portainer beside the app rather than somewhere # separate to remember to look. # # 1560 minutes is 26 hours: the daily interval plus two hours of grace, so # a dump that runs a little late is not reported as a failure. test: ["CMD-SHELL", "find /backups -name '*.sql.gz' -mmin -1560 | grep -q ."] interval: 1h timeout: 30s retries: 3 # Nothing exists until the first scheduled run, so without this the # container reports unhealthy for its first day on every fresh deploy. start_period: 25h restart: unless-stopped logging: driver: json-file options: max-size: 10m max-file: "3" redefined-designs-uploads-backup-syn: image: offen/docker-volume-backup:v2 container_name: redefined-designs-uploads-backup-syn environment: - TZ=America/Chicago # Weekly, not daily. Uploads are append-mostly and much larger than the # database, so a daily full archive would mostly be copies of itself. - BACKUP_CRON_EXPRESSION=0 4 * * 0 - BACKUP_FILENAME=uploads-%Y-%m-%dT%H-%M-%S.tar.gz - BACKUP_ARCHIVE=/archive # Eight weeks. Shorter than the database's tail because each archive is # far bigger, and an image that was deleted two months ago is not # something anyone is restoring. - BACKUP_RETENTION_DAYS=56 - BACKUP_PRUNING_PREFIX=uploads- # Optional. An empty value means no encryption, which is the default. - GPG_PASSPHRASE=${BACKUP_PASSPHRASE} volumes: # Read-only. A backup process with write access to the thing it is backing # up is a way to lose both at once. - /volume1/configs/redefined-designs/uploads:/backup/uploads:ro - /volume1/configs/redefined-designs/backups/uploads:/archive healthcheck: # Nine days: the weekly interval plus two days of grace. test: ["CMD-SHELL", "find /archive -name 'uploads-*' -mmin -12960 | grep -q ."] interval: 6h timeout: 30s retries: 3 start_period: 8d restart: unless-stopped logging: driver: json-file options: max-size: 10m max-file: "3"