The uploads directory is the only place in this application where content someone else authored is served over HTTP. #95 stopped a dangerous file being stored; this stops a stored file doing damage if one ever gets there anyway — through a gap, a path added later, a restore, or a file written before that validation existed. Two halves, complementary rather than alternative. The app's own origin now serves uploads defensively. An allowlist of the three extensions the upload path can produce, so a `.html` or a `.svg` on disk is simply not a file this application hands out — 404, the same answer as a file that is not there, so the response cannot be used to learn which paths exist. An allowlist rather than a denylist because a denylist has to anticipate every type a browser might execute, which is a moving target across browsers and years, while this only has to know three. The content type is stated explicitly from that same list rather than sniffed or guessed from a name someone else chose, paired with `nosniff`. `default-src 'none'; sandbox` gives a directly-navigated file no capabilities at all, which is the only way one of these can do harm — an `<img>` embed does not execute script. Writes get 405 rather than falling through to a 404 that suggests the path is wrong. The other half is the separate origin, which is the real fix, because the origin is the whole unit of trust in a browser. That needs a hostname and a certificate, which live outside this repository, so what is here is the switch: `UPLOADS_BASE_URL`, sent to the frontend at runtime through `/api/config` and joined onto stored paths by `uploadUrl`. Empty means the app's own origin, which is the default and what local development has, so nothing changes until it is pointed somewhere. Stored paths stay site-relative. A stored value outlives any hostname baked into it, and rewriting them would be a migration to undo the day the hostname changes. Runtime rather than built in, so one image serves every environment — the same reason `paypalClientId` and `demoMode` are already there. `UPLOADS_BASE_URL` has a line in `docker-compose.prod.yml` while still empty, deliberately: a Portainer stack variable with no line there is substituted into the file and never reaches the container, which is exactly how `UPLOADS_DIR` went missing on 2026-08-23. Unset warns at boot, in the same shape as the admin gate — a working configuration with one defence switched off is worth saying out loud. Set without a scheme is refused outright, because a bare hostname joins onto a stored path as if it were relative and breaks every image on the site rather than failing visibly. The compose guard now resolves `${VAR:-default}` to its default, which is what the container actually receives when the stack variable behind it is unset. A bare `${VAR}` is still left opaque, so a required variable referenced that way goes on counting as present — that check is about the line existing, not about the stack being filled in. Closes #103
346 lines
16 KiB
YAML
346 lines
16 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.
|
|
#
|
|
# 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
|
|
#
|
|
# 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". 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
|
|
# 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.
|
|
#
|
|
# 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:
|
|
# 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
|
|
|
|
# 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}
|
|
|
|
# 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:-}
|
|
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"
|