Files
redefined-designs/docker-compose.prod.yml
T
bermudalambandClaude Opus 5 66cafeb89c docs(compose): keep the prod comment block's sentences with their own variable (#281)
ANTHROPIC_API_KEY's explanatory paragraph already had a dangling continuation trailing after later entries. When REMBG_URL was added, its entry was inserted ahead of that continuation, so the file read as though "put a spend limit on the key in the Anthropic console" belonged to the background-removal sidecar rather than to Anthropic. This file is read during the cutover runbook, so a misattributed sentence there is not just cosmetic.

Reordered the comment lines so ANTHROPIC_API_KEY's full paragraph is contiguous and REMBG_URL's own two-line entry stands on its own at the end. No environment: line was touched — only the comment block above the services: section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:43:07 -05:00

428 lines
21 KiB
YAML

# 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.
# 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:-}
# 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:-}
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://<nas-ip>: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"