fix(compose): keep the CMS opt-in so it cannot claim the CI port
CI / check (push) Successful in 36s
CI / tests-imaging-browsers (push) Successful in 1m49s
CI / tests-integration (push) Successful in 1m59s
CI / tests-unit (push) Successful in 2m4s
CI / tests-quality (push) Successful in 42s
CI / tests-ui (push) Successful in 4m39s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 3m20s

On a CI-managed host scripts/ci-deploy.sh owns port 3002 through the
blue/green slots, so a compose replica of the cms service could only ever
crash-loop on EADDRINUSE — it never serves traffic, because nginx points at a
slot container. Gate the service behind the `cms` profile so a bare
`docker compose up -d` starts only byparr. Naming the service explicitly
(`docker compose up -d cms`, which is what the installer and updater already
do) still enables the profile automatically, so the compose-only install path
keeps working.

Keep the one port that is actually served (3002) and make the 2 GB ceiling
hard by capping swap at 2g as well. Drop the green compose service (3003):
the green slot only ever exists as a docker run container created by CI, so
compose no longer defines it and the two paths cannot drift apart.

The loopback override contract stays covered: proxy-config.test.mjs now
renders the configuration with --profile cms, and a new build-contract test
pins the profile and the memory limits so neither can be dropped by accident.
This commit is contained in:
openhands committed 2026-10-19 19:00:07 +02:00
1 parent 5c428c4526
commit 597287fce5
5 files changed
+52 -22

No files matched your search

+13 -6
View File
@@ -965,11 +965,13 @@ there are two fixed slots, and each release lands in the idle one:
| A | 3002 | `epicnext-cms-app` |
| B | 3003 | `epicnext-cms-green` |
`docker-compose.yml` defines both services (`cms`, and `cms-green` behind the
`green` profile) from one shared YAML anchor so they cannot drift apart. Note
that the CI deploy path deliberately uses `docker run` rather than compose, so
the resource limits are declared in **both** places — limits that only existed
in compose would never apply to a real release.
`docker-compose.yml` defines a single `cms` service (the blue slot on 3002)
behind the `cms` profile, so a bare `docker compose up -d` never starts it. The
green slot (`epicnext-cms-green`, port 3003) only ever exists as a `docker run`
container created by the CI deploy; compose does not define it, so the two paths
cannot drift apart. Note that the CI deploy path deliberately uses `docker run`
rather than compose, so the resource limits are declared in **both** places —
limits that only existed in compose would never apply to a real release.
### Compose on a CI host
@@ -977,9 +979,14 @@ Compose and CI both want port 3002, so only one of them can own a host. A stray
compose replica (`docker compose up`, or the daily `scripts/docker-update.sh`
cron) parked an `epicnext-cms` container on the blue slot while nginx served the
green slot, and every later release stopped on "Port 3002 is already in use" —
after the build, the migrations and the browser gate. Two guards now prevent
after the build, the migrations and the browser gate. Three guards now prevent
that:
- The `cms` service sits behind the `cms` profile, so a bare `docker compose up
-d` starts only byparr and can never claim 3002. The service starts only when
it is named explicitly (`docker compose up -d cms`), which is what the
installer/updater already do; Compose enables the profile automatically for an
explicitly named service.
- `scripts/docker-update.sh` refuses to run on a CI host. It used to test only
`epicnext-cms-app`, but after a cutover to the green slot that container is
stopped and deleted, so the guard stopped firing while the host stayed
+22 -13
View File
@@ -1,5 +1,20 @@
# ─────────────────────────────────────────────────────────────────────────────
# Next.js CMS — blue/green
# Next.js CMS — single slot on port 3002
#
# ── Eigenaar van poort 3002 ─────────────────────────────────────────────────
# Deze repo kent twee deploy-paden die allebei op poort 3002 willen:
# * `scripts/ci-deploy.sh` (blue/green, docker run) — de eigenaar op een
# CI-host. Elke push naar main bouwt daar een nieuwe image, draait de
# migrations en zet nginx pas om na health + e2e + imaging.
# * deze compose-`cms`-service — bedoeld voor een LOSSE host zonder CI.
#
# Ze kunnen niet naast elkaar draaien: wie op 3002 zit, blokkeert de ander met
# `EADDRINUSE`. Daarom staat `cms` achter een profile. Een bloot
# `docker compose up -d` start dus alleen byparr en kan nooit de live CI-release
# van de site drukken. De service start alleen als je hem expliciet noemt
# (`docker compose up -d cms`, `docker compose build cms`, ...): Compose schakelt
# het profile van een expliciet genoemde service vanzelf in. Op een CI-host wil
# je dat dus juist NOOIT doen.
# ─────────────────────────────────────────────────────────────────────────────
x-cms: &cms
image: epicnext-cms:${CMS_RELEASE:-local}
@@ -14,8 +29,8 @@ x-cms: &cms
dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# Runtime host networking IS required: blue/green needs per-release host ports
# (3002/3003) and nginx reaches the slot over 127.0.0.1.
# Runtime host networking IS required: nginx reaches the CMS over 127.0.0.1
# on the single port that is actually served (3002).
network_mode: host
stop_grace_period: 15s
restart: unless-stopped
@@ -28,8 +43,8 @@ x-cms: &cms
- /var/www/Gamedata:/var/www/Gamedata
# ── Resource limits ──
mem_limit: 6g
memswap_limit: 7g
mem_limit: 2g
memswap_limit: 2g
cpus: 2.0
pids_limit: 512
@@ -43,19 +58,13 @@ x-cms: &cms
services:
cms:
<<: *cms
# Alleen starten als je deze host bewust compose-only beheert.
profiles: ["cms"]
container_name: epicnext-cms
environment:
- HOSTNAME=0.0.0.0
- PORT=3002
cms-green:
<<: *cms
container_name: epicnext-cms-green
profiles: ["green"]
environment:
- HOSTNAME=0.0.0.0
- PORT=3003
byparr:
image: ghcr.io/thephaseless/byparr:latest
container_name: byparr
+1 -1
View File
@@ -81,7 +81,7 @@ The direct template intentionally records the CDN/edge socket address when place
### Local verification and deployment boundary
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
`pnpm exec vitest run --coverage.enabled=false scripts/proxy-config.test.mjs` runs the installed Docker Compose CLI against a disposable clone configuration, without contacting Docker Engine, building images or reading the real `.env`. It verifies selection through `.env`, loopback/port override precedence, the IPv4 healthcheck and preservation of mounts, release selection and host networking. The `cms` service is behind the `cms` profile (so a bare `docker compose up -d` cannot claim port 3002 on a CI-managed host), so the test renders the configuration with `--profile cms`. It explicitly skips when Compose is unavailable. This is not a container-start or network-isolation test.
`pnpm test:integration` additionally starts disposable Nginx containers from the actual templates, supplies a temporary test certificate, and sends real HTTPS requests with forged identity headers. It checks direct-mode replacement even with an inherited real-IP rule, rejection of untrusted peers, and acceptance through an explicitly trusted peer. This requires Docker Engine and the OpenSSL CLI and does not read deployment credentials. The templates must still pass `nginx -t` on the intended host after its hostname/certificate substitution, then the listener and trusted-header checks above; the disposable fixture cannot certify that host or its firewall.
+5 -2
View File
@@ -18,7 +18,7 @@ const hasCompose =
}).status === 0;
it.skipIf(!hasCompose)(
"loads the loopback profile from .env for bare Compose commands without changing mounts or host networking",
"loads the loopback override from .env for the cms service without changing mounts or host networking",
() => {
const directory = mkdtempSync(path.join(tmpdir(), "cms-proxy-config-"));
try {
@@ -50,9 +50,12 @@ it.skipIf(!hasCompose)(
["CMS_RELEASE", "HOSTNAME", "PORT"].includes(key)
)
delete environment[key];
// `cms` is behind the `cms` profile so a bare `docker compose up -d`
// can never squat port 3002 on a CI-managed host. Selecting the
// profile here keeps the loopback/port-override contract under test.
const result = spawnSync(
"docker",
["compose", "config", "--format", "json"],
["compose", "--profile", "cms", "config", "--format", "json"],
{ cwd: directory, env: environment, encoding: "utf8", timeout: 15_000 },
);
expect(result.status, result.stderr).toBe(0);
+11
View File
@@ -47,6 +47,17 @@ it("passes a compiled release to both the application build and final image", ()
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
});
it("keeps the compose CMS opt-in and inside the 2 GB limit", () => {
const compose = readFileSync("docker-compose.yml", "utf8");
// A bare `docker compose up -d` starts only byparr. On a CI-managed host the
// CMS owns port 3002 through ci-deploy.sh; letting compose claim the same
// port again produces an endless EADDRINUSE crash loop.
expect(compose).toContain('profiles: ["cms"]');
expect(compose).not.toContain("cms-green");
expect(compose).toContain("mem_limit: 2g");
expect(compose).toContain("memswap_limit: 2g");
});
it("builds with fixtures and excludes installation secrets from every stage", () => {
const ignored = readFileSync(".dockerignore", "utf8");
expect(ignored).toMatch(/^\.env$/m);