The 3am Question#
Your phone buzzes at 3am. One of your services is doing things you never wrote. How it got in (a poisoned dependency, an unauthenticated endpoint, an unpatched CVE) matters tomorrow.
Tonight, one question matters: what can that process reach?
Most teams have never asked it out loud. So their answer, by default, is: everything the machine has.
"PM2 vs Docker" Is the Wrong Question#
To be fair first: PM2 is genuinely good at its job. So is systemd. Restarts, clustering, zero-downtime reloads that is supervision, and it works. (Production in a detached tmux session is another matter that is not a supervisor, that is a terminal you forgot to close.)
The mistake developers make is not picking the wrong tool. It is treating these two as options on one menu. They solve different problems:
| PM2 gives you | Docker gives you |
|---|---|
| Auto-restart & crash recovery | Filesystem, network & process walls |
| Cluster mode across CPU cores | Resource limits & runtime isolation |
| Zero-downtime reloads | Reproducible environment, dev = prod |
| Monitoring & log management | Workloads separated from the host |
A process manager answers "is my process alive?" A container answers "what can my process touch?" No amount of uptime answers the second question.
Both columns are things you want. Only one of them decides what a compromised process can reach and nothing in the left column ever claimed to. The proof they were never alternatives: running PM2 inside a container (pm2-runtime) is a documented, standard pattern. The either/or was invented by deadline pressure, not by the tools.
The Same Ninety Seconds, In a Container#
Same attacker, same entry point, same code execution. Different universe:
$ cat /app/../*/.env
No such file or directory # other services do not exist here
$ whoami
node # uid 1001, not root
$ echo test > /app/x
Read-only file system # nowhere to drop a payload
$ curl -s --max-time 3 169.254.169.254/latest/meta-data/
Connection timed out # IMDS blocked at the network layer
$ redis-cli -h 127.0.0.1 ping
Connection refused # this loopback is the container's own
$ crontab -e
command not found # distroless: no shell tools at allNothing detected the attacker. Everything they reached for simply was not there.
Not "we were not breached." It is: one service was breached, its credentials were scoped to itself, we rotated them, replaced the container, done. A bad Tuesday instead of a company-ending week.
The Walls#
Ships stopped sinking from single breaches when shipbuilders accepted that breaches happen and divided the hull into sealed compartments. A bulkhead does not prevent the hole. It decides how much of the ship the water gets.
That is Docker as a security tool. Each wall kills one step of the attack above:
- ▸Own filesystem other services' secrets don't exist in its universe
- ▸Own network the host's loopback, Redis, Postgres are unreachable
- ▸Blocked metadata no cloud credentials to steal
- ▸Non-root, no capabilities, read-only nothing to install, nowhere to write
- ▸Disposable replace the container and the attacker's work is gone
And isolation is symmetric: every service you wall off protects every other service. On a shared host it is the opposite each service you add exposes all the rest.
The Recipe#
A default FROM node image running as root gives you a fraction of this. The walls come from the hardening use a distroless base, then:
services:
api:
build: .
user: "1001:1001" # non-root
read_only: true # immutable filesystem
tmpfs:
- /tmp:size=64M,noexec,nosuid
cap_drop: [ALL] # no Linux capabilities
security_opt: [no-new-privileges:true]
networks: [edge]
environment:
DATABASE_URL: ${API_DATABASE_URL} # scoped to THIS service
networks:
edge:
data:
internal: true # no route to the internet
# On the host block cloud metadata from containers:
# iptables -I DOCKER-USER -d 169.254.169.254 -j DROP
# On AWS require IMDSv2 with hop limit 1.Honest limits, so nobody oversells this: containers do not fix application bugs, do not protect the data that service legitimately holds, and share the host kernel. A root container with the Docker socket mounted hands the isolation straight back. And you still have to patch.
Start Here#
- ▸Containerize your most exposed service first the public web app. Leave the rest on PM2 for now; they coexist fine.
- ▸Non-root, read_only, cap_drop: ALL from day one. Retrofitting rarely happens.
- ▸Split credentials per service the marketing site should not hold a payment key, containers or not.
- ▸Block IMDS at the host firewall. Ten minutes, closes the worst escalation path on any cloud VM.
Most of the risk reduction is in the first two bullets. The rest is cleanup.
The Point#
"We'll dockerize when we scale" answers a question no attacker is asking. Nobody checks your traffic graph before scanning your IP.
I stay calm when a framework CVE drops not because my code is better than anyone else's. It is because I assumed, years ago, that something in my stack would eventually run code it should not and I built the bulkheads first.
The breach still happens. It just does not get to become the incident.