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>
428 lines
21 KiB
YAML
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"
|