compose.yaml / compose.override.yaml:
- container_name is now ${STACK_NAME:-escapepage}-* (STACK_NAME is a plain var,
not COMPOSE_PROJECT_NAME, so prod keeps its escapepage-* names with no config change)
- every published host port is ${*_PORT:-<current default>}, so prod is unchanged
and a second stack can bind its own (localhost-only) ports
- nginx joins the external nginx_default network so Nginx Proxy Manager can
forward to <stack>-nginx by name
restart.sh:
- scoped to STACK_NAME / COMPOSE_PROJECT_NAME read from docker/.env, so running it
from the test checkout can't touch the prod stack
- host-wide `docker system prune` / `docker builder prune` moved behind --prune-all
Adds docker/.env.test.example and doc/test-environment.md (separate checkout,
env layers, NPM proxy host + Access List IP allowlist, Mercure on test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.7 KiB
Test / staging environment (test.escapepage.com)
Runs a second copy of the full Docker stack on the same server as production,
behind the same Nginx Proxy Manager (NPM). Production is untouched: with no
docker/.env overrides, compose.yaml behaves exactly as before.
How isolation works
docker/compose.yaml derives everything instance-specific from docker/.env:
| Concern | Mechanism | prod (no overrides) | test |
|---|---|---|---|
| Container names | ${STACK_NAME:-escapepage}-* |
escapepage-php … |
escapepage-test-php … |
| Compose project (networks, labels) | COMPOSE_PROJECT_NAME |
docker (unchanged) |
escapepage-test |
| Published ports | ${NGINX_HTTP_PORT:-8080} etc. |
0.0.0.0:8080/8443/3306/8090/8025 |
127.0.0.1:8081/8444/3307/8091/8026 |
| Database files | bind mount ../var/volumes/db |
per checkout | per checkout |
| Web reachability | nginx joined to external nginx_default |
via NPM | via NPM |
STACK_NAME is a plain variable (not the Compose-managed COMPOSE_PROJECT_NAME),
so its :-escapepage default is reliable and production needs no docker/.env
change to keep its escapepage-* container names.
nginx, mercure and mailer all sit on the external nginx_default network,
so NPM forwards straight to escapepage-test-nginx / -mercure by name — no
public host port needed.
Production checkout — optional
Nothing is required. Two optional tidy-ups, each needing a docker compose up -d
to recreate the affected container:
- The
nginxservice now also attaches tonginx_default. If you'd rather have NPM forward toescapepage-nginxby name instead ofhost:8080, recreate it and repoint the NPM proxy host. Otherwise the published8080/8443still work as before and you can ignore this. - Add
STACK_NAME=escapepageandCOMPOSE_PROJECT_NAME=escapepageto the productiondocker/.envto move it off the implicitdockerproject name. Cosmetic; do it only if you want the two stacks named symmetrically.
Standing up the test stack
1. DNS
test.escapepage.com → server IP. (mercure-test.escapepage.com too if you want
live game features.) On Cloudflare, DNS-only ("grey cloud") keeps it low-profile.
2. Separate checkout
git clone <repo> /opt/escapepage-test
cd /opt/escapepage-test
git checkout <branch-to-test>
Use a separate clone, not git worktree — the Docker build context is the
checkout directory and full isolation avoids surprises.
3. Compose env
cp docker/.env.test.example docker/.env
# edit: real passwords, fresh MERCURE_JWT_SECRET (openssl rand -hex 32), reCAPTCHA keys
4. Symfony env overrides
Create /opt/escapepage-test/.env.local in the checkout (git-ignored):
APP_SECRET=<openssl rand -hex 16>
# Behind NPM the forwarded client IP lands from a Docker-private range; trust them
# all on staging so URL generation / HTTPS detection work.
TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
APP_ENV=prod, DATABASE_URL, SITE_BASE_URL, MERCURE_* and MAILER_DSN are
already supplied to the containers by docker/.env.
5. Build & start
./docker/setup.sh
Builds escapepage-test-* images, starts the containers, creates + migrates
escapepage_test, builds assets. Re-run any time; --no-build skips the image
rebuild.
6. Nginx Proxy Manager — proxy host
- Domain:
test.escapepage.com - Forward to:
escapepage-test-nginx, port 443, scheme https (the app nginx force-redirects 80→443 and serves a self-signed cert; leave "Verify SSL" off — same arrangement as production) - SSL: request a Let's Encrypt cert, Force SSL, HTTP/2
- Optional: add response header
X-Robots-Tag: noindex
If you want live game screens, add a second proxy host
mercure-test.escapepage.com → escapepage-test-mercure port 80 (http).
7. Restrict access to your IP — NPM Access List
A host firewall on 80/443 can't help: prod and test share those ports on NPM. Restrict at the proxy instead.
- NPM → Access Lists → Add Access List
- Name: e.g.
staging-allowlist - Authorisation: leave empty
- Access:
Allow <your.public.ip>then a finalDeny all - Satisfy: Any
- Name: e.g.
- Edit the
test.escapepage.comproxy host → Access List → select it. - Do the same on
mercure-test.escapepage.comif you added it.
Everyone else gets 403 at the proxy. Update the IP in one place when it changes.
Defence in depth (optional): in /opt/escapepage-test/docker/nginx/default.conf,
inside the server { listen 443 ... } block:
set_real_ip_from 172.16.0.0/12; # NPM's docker network
real_ip_header X-Forwarded-For;
allow <your.public.ip>;
deny all;
Deploying a new version to test
cd /opt/escapepage-test
git fetch && git checkout <branch> && git pull
docker compose -f docker/compose.yaml -f docker/compose.override.yaml \
exec php composer install
docker compose ... exec php php bin/console doctrine:migrations:migrate -n
docker compose ... exec php php bin/console cache:clear
docker compose ... exec php npm ci && ... npm run build
docker restart escapepage-test-php-worker
(Or just ./docker/setup.sh --no-build.)
Safety notes
docker/restart.shis now scoped toSTACK_NAME/COMPOSE_PROJECT_NAMEfromdocker/.env— running it in the test checkout only touchesescapepage-test-*. The host-widedocker system prune/docker builder pruneit used to always run are now opt-in via./docker/restart.sh --prune-all; don't use that flag while the other stack shares the host.- The test
docker/.envuses its ownDB_NAMEand passwords so a config slip can't reach the production database. MAILER_DSN=smtp://mailer:1026keeps staging mail inside Mailpit instead of sending through Mailgun.