How a cryptominer got into prod, and what we rebuilt in security after
A container that had no business burning CPU was doing exactly that, load pinned near the ceiling. Coin miners are computationally greedy by design, and that's impossible to hide: sooner or later the load stops matching whatever the service is supposed to be doing.
Inside it sat CVE-2025-55182, nicknamed React2Shell: a pre-auth remote code execution bug in Next.js's RSC Flight protocol, CVSS 10.0, disclosed December 3, 2025, already exploited in the wild with coin-miner payloads just like this one. Next.js shipped fixes across 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, and 16.0.7.
Patching that one container was a five-minute job. The harder question was how many others carried the same vulnerability. Close to 40 services run in production, a good share on Next.js — opening each by hand to read a version number wasn't going to keep pace with a live compromise. A bit of scripting around Trivy solved the speed problem, sweeping the fleet in parallel, but underneath it was a methodical audit: the same vulnerability class checked against every image, the same procedure run against every hit — process tree, CPU, established connections.
#What the audit turned up
The Trivy sweep turned up three more containers carrying the same bug: cal, the calcom/cal.com deployment, on Next.js 15.3.3; formbricks, running ghcr.io/formbricks/formbricks:v3.1.5, on 15.1.2; homarr on 15.4.5. Homarr had a second, unrelated problem: CVE-2025-49844, a use-after-free in the Lua interpreter of its bundled Redis 8.0.3-r0, fixed only in 8.0.4-r0.
All three got the same treatment as the first container: process tree, CPU, every established connection. All came back clean: only expected processes, CPU near zero, connections limited to loopback and each service's own database. No miner, no stray connections, nothing suggesting any of the three had been touched.
Confirming which tags were actually patched meant staying away from production: a disposable docker run --rm per candidate image, read-only, and a grep for node_modules/next/package.json inside. cal.com and homarr turned out to be plain cache staleness, a year behind for one and eleven months for the other, and a fresh pull landed both on already-patched 16.1.5 and 16.2.9.
formbricks was worse. Its v3.x tag line had gone quiet seventeen months earlier, frozen on vulnerable 15.1.2, once the project moved to unprefixed tags — current stable, 5.1.4, already runs patched 16.2.6. But v3 to v5 is two major versions at once, dragging in two new baseline services, a Hub and a Cube, per the vendor's own migration guide, so it got scoped out as its own project.
#cal.com went down for a few minutes
Redeploying cal.com hit a separate problem, an ordinary vendor bug freshly baked into v6.2.0, unrelated to security. cal's pipeline runs in two stages: cal-db-migrate applies Prisma migrations first, and only once that exits clean does the app start. The migrations went through fine. Seed, running right after in the same container, did not: Cannot find module './seed.ts', a path-resolution bug shipped inside the new image.
cal refuses to boot until migrate finishes clean, and seed was part of that step, so cal.deployka.ru sat dark for a few minutes. What unblocked it immediately was skipping seed, safe since it only loads default rows a production database full of real content doesn't need. What actually fixed it long-term was pulling npx prisma db seed out of migrate for good.
One detail for next time: the platform's redeploy button can serve up a compose file cached in its database and report success on a deploy that changed nothing. Only a direct POST to the deploy webhook with a push-shaped payload forces an honest clone.
#OpenBao
The single biggest structural change was OpenBao, a self-hosted Vault-compatible secrets manager rolled out fleet-wide, close to 40 services. The reasoning: a container with secrets in plaintext environment variables hands them to anyone who gets code execution inside it, exactly what had just happened with the miner. Every secret now sits behind a bao:// reference; ordinary configuration stayed as plain env vars.
Each container runs baoenv, a small statically linked Go shim, as PID 1: it resolves bao://mount/path?field references through a per-service AppRole credential, scrubs that credential from the environment, then execs into the real application, which inherits only the final, resolved values.
The rollout briefly broke something: splitting the shared Docker network into per-service networks — groundwork for the isolation work below — shipped to one repository but missed a second, dependent one. Roughly 30 already-migrated services were one redeploy from losing their path to the secrets store. Caught the same day; the fix was running the store on the old network and every new one at once. Not one consumer went down.
#Database isolation
Every datastore used to have one superuser credential shared by every service touching it — convenient, quietly dangerous: a compromised service could read or modify any neighbor's data, not just its own. Each service now gets its own role, minimum privileges, scoped to its own database.
The provisioning script carried a bug nobody caught in review: it ran with ON_ERROR_STOP, and the ownership transfer touched several tables per database; Postgres rejects a direct ownership change on the sequences behind identity and serial columns, so the first rejected statement killed the rest of the batch — the script reported success on every database while transferring almost nothing. A live cutover caught it, when a permission error showed up where it had no business existing.
As of the last check, 9 of 54 database consumers are cut over to their own credentials; the rest — a columnar analytics database, a cache, a second relational database — sit provisioned, reviewed one at a time now rather than trusted to a script.
#Network isolation
Services on one datastore used to share one Docker network, so a compromised service could resolve its way to someone else's database just by knowing its hostname. Dedicated per-service networks close that path, but rolling them out surfaced the same subtle Compose bug more than once.
A service's own bundled datastore, say its own Postgres, joins the network used to reach the secrets store, and Compose gives that container an alias matching the service's generic name — if the name is ordinary, like postgres, it silently shadows the identical alias of the real shared datastore. The connection lands on the wrong container, indistinguishable, from the application's side, from a rejected password.
It hit four times, once on the platform's own identity provider: the workaround required recreating the container, and recreating it also applied a second, unrelated networking change already committed but never applied, from the same file. Authentication went down about two minutes, fixed with a manual network reconnect. The rule that came out of it: before recreating a critical container, read the whole config file for other committed-but-unapplied changes — a recreate ships the file exactly as it stands.
#Rootless, CI, and the rest
Where possible, images moved from root to an unprivileged user: if a container is compromised again, the process inside stays contained at the level of the container itself. This blog's own nginx is the clear example: nginxinc/nginx-unprivileged, uid 101 by default, no root master process, listening on an unprivileged port internally while the host's port mapping handles the translation outward.
The build pipeline picked up govulncheck, gosec, and gitleaks alongside the same Trivy that caught the three containers above — the same vulnerability class now gets checked on every build. Every flagged image got fixed: cal.com and homarr immediately, formbricks on its own track once v5 is properly planned.
Three of those six changes caught their own bug on the way in: the network split that nearly cut off thirty services from the secrets store, the ownership script that silently transferred almost nothing, a Compose alias that kept rerouting connections into the wrong database. All six together should make the next compromised container cost a good deal less than this one did.