Gemini_Generated_Image_1omwip1omwip1omw

Migrating from Nginx Proxy Manager to NPMPlus with CrowdSec: A Complete Walkthrough

TL;DR: I replaced my standard Nginx Proxy Manager instance with NPMPlus (an enhanced fork) and integrated CrowdSec for automated threat detection and blocking — including Cloudflare Turnstile captcha challenges. The whole stack runs in Docker, managed via Dockge, and sits behind itself as a reverse proxy. Here’s exactly how I did it, including the gotchas I hit along the way.


Why Bother?

If you’re self-hosting services behind Nginx Proxy Manager, you’ve probably wondered how to add some real security beyond just SSL termination. The standard jc21/nginx-proxy-manager image doesn’t support CrowdSec natively, and bolting it on manually is messy.

Enter NPMPlus — a fork of NPM maintained by ZoeyVid that comes with CrowdSec bouncer support built in, HTTP/3 (QUIC) support, and various performance improvements. Combined with CrowdSec’s community-driven threat intelligence, you get a reverse proxy that can automatically ban or captcha-challenge malicious IPs based on crowd-sourced blocklists and your own log analysis.

The best part? This very blog you’re reading is sitting behind this exact setup.


What You’ll End Up With

  • NPMPlus as your reverse proxy (drop-in replacement for NPM)
  • CrowdSec Security Engine parsing your access logs and making ban/captcha decisions
  • NPMPlus acting as a CrowdSec bouncer, enforcing those decisions at the nginx level
  • Cloudflare Turnstile captcha challenges for suspicious IPs (instead of just hard bans)
  • AppSec Component enabled for application-layer attack detection
  • Everything managed via Dockge with bind mounts under a single stack directory

Prerequisites

  • Docker and Docker Compose installed
  • An existing Nginx Proxy Manager instance (for migration — fresh installs can skip the migration steps)
  • Dockge or another Docker management tool (optional, but this guide assumes Dockge at /opt/stacks/)
  • Ports 80/TCP, 443/TCP, and 443/UDP available on the host
  • A Cloudflare account if you want Turnstile captcha (free)

Step 1: Back Up Your Current NPM Data

Before touching anything, back up your existing NPM data. You’ll thank yourself later.

bash

cd /opt/stacks/nginx_proxy_manager
cp -r data data.bak
cp -r letsencrypt letsencrypt.bak

Step 2: Pre-Pull the Docker Images

Since there will be a brief window of downtime between stopping the old NPM and starting NPMPlus, pre-pull the images first to minimise it.

bash

docker pull docker.io/zoeyvid/npmplus:latest
docker pull docker.io/crowdsecurity/crowdsec:latest

Step 3: Create the NPMPlus Stack

If you’re using Dockge, create a new stack called npmplus. Dockge will create /opt/stacks/npmplus and place the compose file there. Here’s the compose.yaml:

yaml

name: npmplus
services:
  npmplus:
    container_name: npmplus
    image: docker.io/zoeyvid/npmplus:latest
    restart: unless-stopped
    network_mode: host
    volumes:
      - "./npmplus-data:/data"
      - "/opt/stacks/nginx_proxy_manager/letsencrypt:/etc/letsencrypt"
    environment:
      - "TZ=Africa/Johannesburg"
      - "[email protected]"
      - "LOGROTATE=true"

  crowdsec:
    container_name: crowdsec
    image: docker.io/crowdsecurity/crowdsec:latest
    restart: unless-stopped
    network_mode: bridge
    ports:
      - "127.0.0.1:7422:7422"
      - "127.0.0.1:8080:8080"
    environment:
      - "TZ=Africa/Johannesburg"
      - "USE_WAL=true"
      - "COLLECTIONS=ZoeyVid/npmplus"
    volumes:
      - "./crowdsec/conf:/etc/crowdsec"
      - "./crowdsec/data:/var/lib/crowdsec/data"
      - "./npmplus-data/nginx/logs:/opt/npmplus/nginx/logs:ro"

A few things to note about this compose file:

NPMPlus uses network_mode: host — it binds directly to ports 80, 443 (TCP and UDP), and 81 on the host. There are no Docker port mappings. This means you need to make sure nothing else is listening on those ports.

The LOGROTATE=true environment variable is essential. Without it, NPMPlus doesn’t write access logs to disk, and CrowdSec has nothing to parse.

The Let’s Encrypt volume mount (/opt/stacks/nginx_proxy_manager/letsencrypt:/etc/letsencrypt) is temporary — it’s only there so NPMPlus can import your existing certificates during migration. You’ll remove it later.

All bind mounts use relative paths (./), which Dockge resolves to /opt/stacks/npmplus/. Everything stays neatly under one stack directory.

Don’t start the stack yet — there’s some prep work to do first.


Step 4: Create the CrowdSec Acquisition Config

CrowdSec needs to know where to find the logs and what parsers to use. Create the config directory and file:

bash

mkdir -p /opt/stacks/npmplus/crowdsec/conf/acquis.d

Then create /opt/stacks/npmplus/crowdsec/conf/acquis.d/npmplus.yaml:

yaml

filenames:
  - /opt/npmplus/nginx/*.log
labels:
  type: npmplus
---
filenames:
  - /opt/npmplus/nginx/*.log
labels:
  type: modsecurity
---
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
name: appsec
source: appsec
labels:
  type: appsec

Important: The paths in this file are container paths — CrowdSec sees the logs mounted at /opt/npmplus/nginx/logs inside its container. The actual files on your host live at /opt/stacks/npmplus/npmplus-data/nginx/logs/. This confused me initially, but the bind mount in the compose file handles the mapping.


Step 5: Migrate Your Existing NPM Data

Copy your existing NPM data into the new NPMPlus data directory:

bash

mkdir -p /opt/stacks/npmplus/npmplus-data
cp -r /opt/stacks/nginx_proxy_manager/data/* /opt/stacks/npmplus/npmplus-data/

Step 6: Stop the Old NPM and Start NPMPlus

This is the moment of downtime. Stop your old NPM stack:

bash

cd /opt/stacks/nginx_proxy_manager
docker compose down

Then fire up the new stack (via Dockge or manually):

bash

cd /opt/stacks/npmplus
docker compose up -d

Step 7: Check the Logs and Handle the Certificate Migration

This is where I hit my first surprise. Instead of seeing an initial admin password in the logs, I got this:

All certbot certs have been copied, please remove the /etc/letsencrypt
mountpoint and redeploy to continue the migration!

NPMPlus imported the certificates and now wants you to remove the migration mount before continuing. Edit your compose.yaml and remove the Let’s Encrypt line so the NPMPlus volumes become just:

yaml

volumes:
  - "./npmplus-data:/data"

Then redeploy:

bash

cd /opt/stacks/npmplus
docker compose up -d

Pitfall: Don’t panic when you don’t see an initial admin password. Because this is a migration from an existing NPM database, NPMPlus uses your existing credentials. Log in at https://<your-server-ip>:81 with the same email and password you used on your old NPM instance.

NPMPlus doesn’t need a separate Let’s Encrypt mount long-term — it stores certificates within its own data directory.


Step 8: Connect NPMPlus as a CrowdSec Bouncer

Now wire up NPMPlus to enforce CrowdSec’s decisions. Generate a bouncer API key:

bash

docker exec crowdsec cscli bouncers add npmplus -o raw

Copy the key that’s output, then edit /opt/stacks/npmplus/npmplus-data/crowdsec/crowdsec.conf:

  • Set ENABLED=true
  • Set API_KEY=<the-key-you-just-copied>

Pitfall: This config file doesn’t exist until NPMPlus has started for the first time. If you’re looking for it before Step 6, it won’t be there.

Restart NPMPlus to apply:

bash

docker restart npmplus

Check the logs for confirmation:

bash

docker logs npmplus

You should see lines indicating NPMPlus is connected to CrowdSec.


Step 9: Adding Cloudflare Turnstile Captcha

Rather than just hard-banning suspicious IPs, I wanted to give legitimate users a chance to prove they’re human. CrowdSec supports Cloudflare Turnstile as a captcha provider, and it’s configured in the same crowdsec.conf file.

First, go to the Cloudflare dashboard → Turnstile → Add Site. You’ll get a Site Key and a Secret Key.

Then edit /opt/stacks/npmplus/npmplus-data/crowdsec/crowdsec.conf and update the captcha section:

CAPTCHA_PROVIDER=turnstile
SECRET_KEY=your-turnstile-secret-key
SITE_KEY=your-turnstile-site-key

Restart NPMPlus again:

bash

docker restart npmplus

You can test this by adding a captcha decision for a test IP (use a VPN so you don’t lock yourself out):

bash

docker exec crowdsec cscli decisions add --ip <test-ip> --duration 5m --type captcha

That IP will now see a Turnstile challenge instead of a ban page.


Step 10: Enroll in the CrowdSec Console

The CrowdSec Console gives you a web dashboard to monitor alerts, view blocked IPs, and access the community blocklist. It’s free and worth setting up.

Sign up or log in at app.crowdsec.net, go to the Security Engines page, and click “Add Security Engine”. You’ll see an enrollment key.

Run:

bash

docker exec crowdsec cscli console enroll <your-enrollment-key> --name "your-server-name"

Then go back to the console, find your engine on the Engines page, and click Accept. Restart CrowdSec afterwards:

bash

docker restart crowdsec

You can rename your engine at any time from the console — no need to re-enroll.


A Note on Ports and Firewalls

NPMPlus enables HTTP/3 (QUIC) by default, which runs over UDP 443. If you’re behind a firewall (I’m running a MikroTik), you’ll need to add a UDP 443 forward alongside your existing TCP 80 and TCP 443 rules.

If you don’t want HTTP/3 or can’t forward UDP, add this to your NPMPlus environment variables:

yaml

- "DISABLE_H3_QUIC=true"

Your sites will still work perfectly fine over HTTP/2 and HTTP/1.1.


Using Cloudflare Proxy? Read This

If your DNS entries are proxied through Cloudflare (the orange cloud), there’s an important extra step. By default, NPMPlus does not trust Cloudflare’s IP ranges — which means all traffic appears to come from Cloudflare’s IPs rather than your actual visitors. CrowdSec would end up seeing every request as the same source, making it useless at best and dangerous at worst (it could ban Cloudflare’s IPs entirely).

The fix is simple — add TRUST_CLOUDFLARE=true to your NPMPlus environment variables:

yaml

    environment:
      - TZ=Africa/Johannesburg
      - [email protected]
      - LOGROTATE=true
      - TRUST_CLOUDFLARE=true

This tells NPMPlus to fetch Cloudflare’s IP ranges and extract the real visitor IP from the headers Cloudflare sends. CrowdSec then sees and acts on actual visitor IPs.

A few things to be aware of when running behind Cloudflare proxy:

  • Set your Cloudflare SSL/TLS mode to “Full (strict)” — go to your domain in the Cloudflare dashboard → SSL/TLS → Overview → set encryption mode to Full (strict). Without this, you’ll get redirect loops or connection errors.
  • Cloudflare overrides some NPMPlus settings — things like HSTS headers, HTTP/3, and TLS configuration may need to be configured again in the Cloudflare dashboard, as Cloudflare applies its own settings on top.
  • Cloudflare has a 100MB per-request limit — large file uploads or downloads may fail if chunking isn’t used.
  • CrowdSec and Cloudflare complement each other — think of Cloudflare as your outer wall (DDoS, bulk bot traffic) and CrowdSec as your inner wall (brute force, vulnerability scanning, application-layer attacks). They’re not redundant.

Pitfall: The TRUST_CLOUDFLARE environment variable was recently renamed from SKIP_IP_RANGES (with inverted logic) in the 2026-02-19-r3 release. If you’re following older guides, they may reference the old name — use TRUST_CLOUDFLARE=true instead.


The Final Directory Structure

With everything set up, the stack directory looks like this:

/opt/stacks/npmplus/
├── compose.yaml
├── npmplus-data/           # NPMPlus data, certs, and config
│   ├── crowdsec/
│   │   └── crowdsec.conf   # Bouncer config
│   ├── nginx/
│   │   └── logs/           # Access logs (shared with CrowdSec)
│   └── ...
└── crowdsec/
    ├── conf/               # CrowdSec config
    │   └── acquis.d/
    │       └── npmplus.yaml
    └── data/               # CrowdSec data and decisions

Everything under one directory. Clean, easy to back up, easy to migrate.


Useful CrowdSec Commands

Once you’re up and running, these are the commands you’ll reach for most:

  • docker exec crowdsec cscli decisions list — view active bans and captcha challenges
  • docker exec crowdsec cscli alerts list — view recent alerts
  • docker exec crowdsec cscli bouncers list — check bouncer connection status
  • docker exec crowdsec cscli metrics — view parsing and decision metrics
  • docker exec crowdsec cscli decisions add --ip x.x.x.x --duration 24h --type ban — manually ban an IP
  • docker exec crowdsec cscli decisions delete --all — clear all active decisions

Wrapping Up

The whole migration took me about 30 minutes, including the troubleshooting. NPMPlus is a solid drop-in replacement for the standard Nginx Proxy Manager — the web UI is familiar, all my proxy hosts and certificates migrated cleanly, and the built-in CrowdSec integration means I didn’t have to hack together a custom bouncer setup.

The fact that this blog post is being served through the very stack I just described is a nice bit of dogfooding. If a malicious bot tries to hit this site, CrowdSec will catch it, and NPMPlus will either block it outright or serve a Turnstile challenge — all without me lifting a finger.

If you’re running NPM today and want to level up your security, this is a straightforward upgrade that’s well worth the half hour of effort.


Useful links: