Deploying EzyShield — Docker host with nginx-proxy + multiple WordPress containers
This walks a server admin through protecting a typical setup: one host running Docker, an nginx reverse proxy container in front of several WordPress containers. The attacks you care about here are SSH brute force on the host, WordPress login brute force (/wp-login.php, /xmlrpc.php), and bot/scanner scraping — all blocked at the host firewall (and optionally at Cloudflare).
0. The key idea (read this first)
EzyShield runs on the host, not inside a container. It needs to (a) read the proxy's access logs and the host's SSH logs, and (b) write firewall rules in the host kernel. A container can't safely do either. So we install the binary on the host and just point it at the log files your containers already write.
The one thing you must get right: the real visitor IP has to reach the logs. Behind Docker, your nginx proxy sees the Docker bridge IP unless it's configured to record X-Forwarded-For. Section 3 handles this — if you skip it, EzyShield will try to ban Docker's internal network. (Anti-lockout only protects your current SSH peer and configured admin_cidrs — it won't stop that. Fix the log source properly.)
1. Prerequisites
- Linux host (Ubuntu 22.04+/Debian 12+/RHEL 9+), root/sudo access
nftablesavailable on the host (nft --version)- Your proxy writing access logs to a path on the host (a bind-mount, see §3)
- Optional: a Telegram bot token, and/or a Cloudflare API token
2. Install (on the host)
curl -sfL https://get.ezyshield.com | sudo sh
ezyshield versionOr install the signed .deb/.rpm — see the install guide.
3. Make sure the proxy logs the real client IP
Two parts: the proxy must record the real IP, and EzyShield must be able to read the log file on the host.
3a. Expose the log file to the host
You have two options — pick one:
Option A — bind-mount the proxy's log dir (explicit, simplest to reason about):
services:
nginx-proxy:
image: nginxproxy/nginx-proxy # or your own nginx
volumes:
- /var/log/nginx-proxy:/var/log/nginx # <-- host path : container path
# ...Now the host sees access logs at /var/log/nginx-proxy/access.log.
Option B — just read Docker's own captured stdout (no bind-mount needed): If your containers log to stdout (the default for official nginx/WordPress images) and you use the json-file driver with rotation — like the popular evertramos/nginx-proxy-automation setup does — Docker already stores those logs on the host at:
/var/lib/docker/containers/<container-id>/<container-id>-json.logEzyShield can read these directly — find the container id with docker ps --no-trunc. Set a sane rotation in your compose so the files don't grow forever:
logging:
driver: json-file
options: { max-size: "10m", max-file: "5" }Option B is convenient and keeps your compose clean; Option A gives you a stable, human-readable path independent of container IDs (which change on recreate). If you recreate containers often, prefer A — the path in B changes with the container ID.
3b. Record the real client IP
If clients hit nginx directly, default logs already contain the real IP — done.
If there's something in front (Cloudflare, a load balancer, another proxy), nginx sees that as the client. Configure real_ip so the logged $remote_addr is the true visitor (and so EzyShield doesn't ban your CDN):
# in the proxy's nginx config
set_real_ip_from 173.245.48.0/20; # your trusted upstream / Cloudflare ranges
real_ip_header X-Forwarded-For;
real_ip_recursive on;Critical safety note: only trust
X-Forwarded-Forfrom upstreams you actually control (theset_real_ip_fromranges above). If the proxy trusts it from everyone, attackers spoof the header and can get innocent IPs banned. EzyShield reads whatever real IP the proxy resolves into the log line — get the nginx side right and EzyShield bans the right address.
3c. Per-container WordPress logs (optional)
If you'd rather read each WordPress container's own access log, bind-mount each one out and add them all in §4. Usually the single proxy log is enough and simpler — start there.
4. Configure EzyShield
sudo ezyshield init # interactive wizard; writes /etc/ezyshield/*.yamlPre-flight: before printing the "Detecting environment..." banner,
ezyshield initstats<config-dir>/config.yamland<config-dir>/policy.yaml. If either already exists, the wizard fails fast (within ~1s) with a single error listing every pre-existing path — so you don't answer the entire questionnaire only to be told at the end that it couldn't write. To regenerate, delete the listed files and re-run. The same check honours--config-dir <path>for non-default target directories.
Or write /etc/ezyshield/config.yaml directly. Collectors read your logs; enforcement and notifications are configured here, while thresholds and the allowlist live in policy.yaml:
# /etc/ezyshield/config.yaml — what to watch and how to act
collectors:
- kind: journald # host SSH brute force
unit: ssh
- kind: file # the proxy's access log
path: /var/log/nginx-proxy/access.log
parser: nginx
enforce:
nftables: {} # local firewall (default table/set)
notify:
telegram:
bot_token: env:EZYSHIELD_TELEGRAM_TOKEN # secrets come from env, never inline
chat_ids: ["987654321"]# /etc/ezyshield/policy.yaml — decisions, escalation, and safety
armed: false # dry-run until you're confident (default)
ban_threshold: 70
strikes:
- ttl: 5m
- ttl: 1h
- ttl: 24h
- ttl: 168h
- ttl: 0 # permanent
# Never block these — your own access. Current SSH peer + admin_cidrs are
# auto-allowlisted before every ban.
allowlist:
- 203.0.113.7 # your home/office IP (CHANGE THIS)
admin_cidrs:
- 192.0.2.0/24WordPress signatures (wp-login.php / xmlrpc.php floods, exploit-probe paths) are built into the shipped rules — no configuration needed. To customize thresholds, uncomment the relevant rule in /etc/ezyshield/rules.d/10-wordpress.yaml (written by init) and adjust — see Customizing Detection Rules.
Secrets go in an env file the systemd unit loads (ezyshield init creates it at mode 0600; doctor checks its permissions):
sudo tee /etc/ezyshield/.env >/dev/null <<'EOF'
EZYSHIELD_TELEGRAM_TOKEN=123456:abc...
EOF
sudo chmod 600 /etc/ezyshield/.env5. Verify before you arm it
sudo ezyshield doctor # checks config, perms, nft, log readability
sudo ezyshield config validate # strict schema check
sudo ezyshield test notifier telegramThen run the daemon in the foreground and watch what it would do (it stays in dry-run until you set armed: true):
sudo ezyshield run # logs "dry_ban (would ban ...)" decisionsLet this run during real traffic for a day. Confirm:
- it flags actual attackers (try a few bad SSH logins from your phone's hotspot)
- it does not flag your own IP, your CDN, or the Docker network
- the IPs shown are real visitor IPs, not
172.xDocker addresses (if they are, fix §3b)
6. Arm it
Flip armed: true in config, then run it for real as a service:
The systemd units are installed by ezyshield init (or the deb/rpm package). Enable and start:
sudo systemctl enable --now ezyshield-enforcer ezyshield
systemctl status ezyshieldNow bans are live. Watch them:
ezyshield status # daemon/enforcer health, mode, active bans
ezyshield list # currently banned IPs + strike # + expiry
ezyshield watch # live event stream in your terminalManual control any time:
sudo ezyshield ban 203.0.113.7 --ttl 24h --reason "manual"
sudo ezyshield unban 203.0.113.7
sudo ezyshield allow 198.51.100.9 # add to allowlist7. Optional: also block at Cloudflare
If your WordPress sites sit behind Cloudflare, blocking at the edge stops attackers before they even reach your host:
enforce:
nftables: {}
cloudflare:
api_token: env:CLOUDFLARE_API_TOKEN # scope it to "Account Filter Lists: Edit"
account_id: "your-account-id" # required in the default "lists" modeEzyShield then writes bans to both the host firewall and Cloudflare, and keeps them in sync. See the Cloudflare guide for token scoping and the lists-vs-rulesets modes.
8. Optional: turn on AI analysis
The rule engine works with no AI at all. To let AI judge the ambiguous cases (is this aggressive crawler a real user or a scraper?):
ai:
provider: anthropic # anthropic | openai | ollama
model: claude-haiku-4-5-20251001
api_key: env:ANTHROPIC_API_KEY
token_budget_daily: 50000 # hard daily cap; rule engine takes over if exceededOnly suspicious aggregates get sent, already minimized to summaries like IP 203.0.113.7 → 412 POSTs to /wp-login.php in 60s, and verdicts are cached — so token usage stays tiny.
9. If something goes wrong — panic button
Stop new bans immediately and drop every local block at once:
sudo systemctl stop ezyshield # daemon stops deciding
sudo nft delete table inet ezyshield # all local blocks gone in one commandEzyShield keeps every rule it writes inside its own inet ezyshield table and never touches rules outside it — deleting that table clears all of EzyShield's local blocks and nothing else. It also never blocks your active SSH session (anti-lockout re-checks before every ban).
To unblock a specific IP everywhere (host and the configured Cloudflare edge):
sudo ezyshield unban 203.0.113.7Cloudflare edge entries are removed per-IP by unban. To clear an entire edge list at once, use the Cloudflare dashboard (Manage Account → Configurations → Lists) — a block at the edge keeps rejecting traffic even after you stop the local daemon, so don't forget it.
To remove EzyShield from the host completely, use scripts/wipe.sh (stops and removes services, units, binaries, nftables rules, the service user, and — optionally — data).
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
It's banning 172.x.x.x / Docker IPs | proxy logs container IP, not client | configure nginx real_ip (§3b) |
| Nothing is detected | wrong log path or the parser can't match it | ezyshield doctor; check the collector's path/parser in config.yaml |
| Got briefly locked out | allowlist missing your IP | anti-lockout should prevent it; add your IP to allowlist |
| Telegram silent | token/chat_id or env not loaded | ezyshield test notifier telegram; check .env perms |
| Real visitors blocked | proxy trusts XFF from untrusted source | tighten set_real_ip_from to upstreams you control |
TL;DR
- Install the binary on the host (not in a container).
- Bind-mount your proxy's access log to the host; make sure it logs the real client IP.
ezyshield init, set your IP in the allowlist, keeparmed: false.sudo ezyshield runfor a day (dry-run mode;armed: false), confirm it's sane.- Flip
armed: true,systemctl enable --now ezyshield. - (Optional) add Cloudflare edge blocking and/or AI analysis.