Playbook: W3/W4 Redeploy Idempotency — making fixes survive a CI redeploy
Created: 2026-07-18
Source issue: radieu/p24-infra#4258 — [PLAN] W3/W4 redeploy not idempotent — patches lost on CI redeploy
Owner: sys-admin / secret-manager on bms-1 (W3/W4 stack — see w3-w4-stack-operations.md)
Status: tracking / coordination runbook. This document is the design-phase deliverable for the recurrence class; the individual fixes it sequences live in their own issues.
One-line rule of this runbook: on the Pinbox24 W3 (
v32-prod) / W4 (v42-prod) stack, a redeploy restores last-committed git / image state and the SOPS-synced env, and reverts everything else. If a fix does not live in one of those three places, assume the next redeploy silently deletes it. “Fixed on the box” is not fixed.
See also:
pinbox24-w3-w4-health-verification.md— if you’re here because a fix that worked yesterday looks broken again, run that playbook’s quick checklist first to confirm the symptom, then come back here for the redeploy-survival model.
1. What this is — the failure class
W3 and W4 on bms-1 redeploy through GitLab CI (image rebuild + push to ECR → docker compose pull +
force-recreate) with env files re-injected from SOPS. Any change applied outside committed
git/image state or the synced SOPS env is silently reverted on the next redeploy — with no error, no
alert, and no diff, because the redeploy is doing exactly what it was told to do: reproduce the
last-committed state.
This is a structural regression class, not a series of one-off mistakes. Three confirmed instances this session all share one shape:
“A redeploy restores last-committed git/image state and reverts fixes applied elsewhere.”
The point of this runbook is to make that shape visible, give a hard checklist for “will this survive the next redeploy?”, and record the ordered dependency chain that closes the class for good.
2. The redeploy model — what a redeploy actually restores
A “redeploy” on this stack is any of:
- GitLab CI
build:*+deploy:*(image rebuild → ECR →docker compose pull+ force-recreate) — seew3-gitlab-ci-image-pipeline.md/w4-gitlab-ci-image-pipeline.md - The nightly-window (
20:00–06:00 UTC) scheduleddeploy:*:nightlyjobs - A manual force-recreate fallback while the runner is down
(
pinbox24-bms1-manual-deploy.md) secrets-sync.ymlre-writing the on-disk env files from SOPS and--force-recreate-ing the affected containers
Every one of those rebuilds container state from a declared source. Nothing that only exists in running-container memory or in a hand-edited on-box file that no declared source reproduces survives.
The four layers of state — and which survive a redeploy
| # | Layer | Declared source (survives) | Reverted on redeploy if the fix lives only… |
|---|---|---|---|
| 1 | App code | The image built from the GitLab repo (p24-v-3.2 / p24-back-ts) | …as a manual edit inside the running container’s filesystem |
| 2 | Persistent patches | persistent-patches/* files, volume-mounted at runtime (NOT baked into the image — Gap 8, 2026-07-11) and tracked in docs/bms-1/docker-compose-{w3,w4}.yml | …as an edit to the mounted target inside the container instead of to the source patch file, or if the bind-mount is dropped on force-recreate |
| 3 | Secrets / env | SOPS (secrets/pinbox24-w3.env.sops, pinbox24-w4.env.sops, pinbox24-backends.env.sops, bms-servers.env.sops) → synced to the on-disk .env by secrets-sync.yml | …as a hand-edited on-disk env value that a hardcoded plaintext copy in the deploy script re-clobbers (root cause #2855) |
| 4 | Config / capability | ansible role + the git-tracked compose file | …as an on-box cron/toggle/flag that no ansible role or compose file reproduces |
Layers 3 and 4 are where the class bites. Layer 3 has a contested source of truth: SOPS is authoritative in principle, but the CI/image and the bms-1/GitLab deploy scripts still carry stale plaintext copies that win on redeploy, so credential rotations don’t stick. Layer 4 has no source of truth for on-box fixes — crons and toggles applied by hand are simply lost on rebuild.
3. The three confirmed lost patches
| # | What was lost | Layer | Why it reverted | Fix / tracking |
|---|---|---|---|---|
| #4169 | W4 Mailgun idempotent-sync guard | 3 (secrets) | The upsert_key guard shipped for W3 on 07-09 (see mailgun-mongodb-stale-credential-hang.md), but W4’s secrets-sync step never got it → v42-prod redeploy re-clobbered the Mailgun key → all W4 password resets down again | Parity fix #4170 (P2) |
| #3905 | W3 env value (rotated credential re-exposed) | 3 (secrets) | Recurs (#3614 / #3066 / #3750 / #3216) because root cause #2855 — hardcoded creds in the bms-1/GitLab deploy scripts — was never fixed; every redeploy re-injects the old plaintext, so rotations don’t stick | Rotate after #2855 lands — see chain below |
| #3738 | W3/W4 Mongo rotation cron | 4 (config) | Disabled 07-05 for a heredoc bug that is since fixed (#2639 / #2672) but never re-enabled, and never codified in ansible → a redeploy-adjacent capability silently lost | Codify in ansible; re-enable per user decision (deferred to 2026-07-28+) |
All three are the same bug wearing different clothes: the fix lived somewhere a redeploy does not reproduce.
4. Root causes
No single source of truth survives a redeploy for layers 3 and 4:
- Secrets (layer 3) — contested authority. SOPS is authoritative by policy, but the CI/image and the bms-1/GitLab deploy scripts carry stale plaintext copies that win on redeploy. Until the injection source is fixed (#2855), any W3/W4 rotation is burned on the next CI run.
- Config (layer 4) — no authority at all. Fixes applied on the box (crons, toggles, feature flags) are not codified in ansible or the image, so a rebuild has nothing to reproduce them from.
The general principle both violate: a redeploy is a pure function of its declared sources. Any state not reachable from those sources is, by construction, ephemeral.
5. The idempotency invariant
A fix is redeploy-safe if and only if re-running the deploy from its declared sources reproduces the fix byte-for-byte.
Concretely, before you consider any W3/W4 change “done”, it must land in exactly one of:
- App/patch behaviour → a committed change to the GitLab repo or a
persistent-patches/*source file that is bind-mounted by the git-trackeddocker-compose-{w3,w4}.yml. Never an in-container edit. - A secret value → the SOPS file and every plaintext copy the deploy path reads must be eliminated or made to defer to SOPS (this is what #2855 fixes). Rotating a key without closing the plaintext-injection path is not a fix — it is a countdown.
- A capability/config → an ansible role or the git-tracked compose file. Never a bare on-box cron.
If a change can’t be expressed as one of those three, it is not redeploy-safe and must not be recorded as resolved — track it as tech-debt with the reason.
6. Ordered dependency chain — the hardening effort
This is the sequence that closes the class. Order matters: rotating exposed keys before the injection source is fixed just burns the fresh value on the next redeploy.
① #2855 Fix the injection source FIRST
Strip hardcoded creds from the bms-1 / GitLab deploy scripts so redeploy reads only SOPS.
└─ Until this lands, ANY W3 rotation is burned on the next redeploy.
│
▼
② #4170 Port W3 → W4 parity
upsert_key + sha256-verify into the W4 secrets-sync step so a v42-prod redeploy
cannot clobber synced W4 secrets (the guard W3 already has; #4169's lost patch).
│
▼
③ #3738 Codify the Mongo rotation cron in ansible
Not on-box. Re-enable per the user decision (deferred to 2026-07-28+).
│
▼
④ #3216 (W4) / #3905 (W3) ONLY NOW rotate the exposed keys
With ①–③ in place the fresh value survives the next CI run. Doing this earlier
means the rotation dies on the next redeploy.
| Step | Issue | Role | Gate before starting |
|---|---|---|---|
| ① | #2855 | sys-admin + human (GitLab MR merge) | GitLab runner up (#2690); GITLAB_ADMIN_PAT group access (#3775) |
| ② | #4170 | sys-admin (secrets-sync.yml) | ① merged |
| ③ | #3738 | sys-admin (ansible) | User re-enable decision (revisit 2026-07-28+) |
| ④ | #3216 (W4) · #3905 (W3) | secret-manager | ①–③ complete |
7. Preventive patterns (the guards each step installs)
These are the concrete techniques the chain codifies — reuse them for any new W3/W4 fix.
7.1 --force-recreate, never restart (layers 1–3)
docker restart reuses the environment baked in at container creation and does not re-read the
host env file — a rotated credential on disk is ignored until the container is recreated. Every
env-sensitive deploy/rotation step must docker compose up -d --force-recreate (or the
rename-old + up -d pattern). This is the exact flag whose absence caused the #4169 mailgun hang.
7.2 upsert_key + sha256 verify (layer 3)
A secrets-sync step must (a) upsert each key idempotently rather than assume prior state, and
(b) verify the value actually loaded by comparing the on-disk hash against the running container’s:
# Read-only, prints no secret value — hashes must match after a force-recreate.
grep "^KEY_NAME=" /path/to/on-disk.env | sha256sum
docker exec <container> sh -c "cat /proc/1/environ | tr '\0' '\n' | grep '^KEY_NAME=' | sha256sum"
# Differing hashes = the container is running a stale credential → recreate, don't restart.This is the W3 guard (#4169) that #4170 ports to W4.
7.3 Codify in ansible / compose, never on-box (layer 4)
A cron, toggle, or flag applied by hand does not survive a rebuild. It must live in an ansible role
or the git-tracked docker-compose-{w3,w4}.yml. #3738’s cron is the standing example.
7.4 Persistent patches stay bind-mounted, source in git (layer 2)
Patches are volume-mounted at runtime (Gap 8) and their source files are tracked. Edit the
persistent-patches/* source, never the in-container target, and confirm the mount survived a
force-recreate:
docker inspect <container> --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{println}}{{end}}'8. Verification — before and after any W3/W4 redeploy
Before applying a fix, classify it against §5. If it is not expressible as one of the three declared sources, stop — it is not redeploy-safe.
After any redeploy (CI, nightly, or manual force-recreate), confirm the fix survived:
- Secrets — on-disk env hash == running-container env hash for every rotated key (§7.2). No plaintext copy in the deploy script still shadows SOPS (§6 ①).
- Patches — every
persistent-patches/*bind-mount is present (docker inspect … .Mounts) and the patched behaviour is live. - Config — the cron/toggle/flag is present because ansible/compose declares it, not because someone re-applied it by hand.
- App — the deployed image tag matches the intended build (GitLab pipeline history records
the exact
merged-<date>-<sha>tag).
If any box fails, the fix was not redeploy-safe — re-express it per §5 and re-file, do not re-apply on the box.
9. Error reporting (repo standard)
If any step in this chain fails, report before working around — do not silently patch on the box:
- Discord embed via
P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL. - GitHub issue in
radieu/p24-infralabelledbug.
Related
docs/w3-w4-stack-operations.md— W3/W4 ownership, permission matrix, deploy path, escalationdocs/playbooks/mailgun-mongodb-stale-credential-hang.md— the #4169 lost patch + the--force-recreate/ sha256-verify guardsdocs/playbooks/w3-gitlab-ci-image-pipeline.md·w4-gitlab-ci-image-pipeline.md— the redeploy pipelines (Gap 8 patch-mounting, nightly window)docs/playbooks/pinbox24-bms1-manual-deploy.md— force-recreate fallback while the runner is downdocs/playbooks/w3-w4-rotation-orchestration.md— rotation SOP (secret-manager)- Chain: #2855 (injection source) → #4170 (W4 parity) → #3738 (cron in ansible) → #3216 / #3905 (rotate exposed keys)