NPMplus + OpenAppsec

Finally settled on a WAF for my production and development applications
Took a considerable amount of time and experimentation, something that I don't have a lot of lately after the new year but finally catching up on a lot of things and the hypervisor rebuild expedited a great deal.
As I stated previously becoming more independent of cloud services and hosting more internally, which requires a WAF.
So here is the link, so you know what the code at the bottom of this post is for
There is some serious configuration that needs to go on to get this working
- I recommend a cloud management deployment at this stage
- You need to organize your stacks in your hypervisor
- It chews CPU cycles so don't place your intensive services in the same stack like GitLab
- You need to bring this stack up first and then attach other stacks as required to its network
So, looking for a docker compose and environment file here you go.
# Stack configuration for NPMplus with OpenAppSec and Cloudflared
# This file defines the services and their configurations for running NPMplus with OpenAppSec and Cloudflared.
# Development Notes:
# - Ensure you have the necessary environment variables set in a .env file.
# - The NPMplus service will manage SSL certificates for the specified domain.
# - OpenAppSec will provide application security features.
# - Cloudflared will handle secure tunneling to the NPMplus service.
# THIS IS A WORKING CONFIGURATION FILE
# Updated 21-07-2025
services:
# https://github.com/maxmind/geoipupdate
geoipupdate:
image: docker.io/maxmindinc/geoipupdate:latest
container_name: npmplus-geoipupdate
hostname: npmplus-geoipupdate
restart: always
environment:
TZ: ${TZ}
GEOIPUPDATE_EDITION_IDS: GeoLite2-Country GeoLite2-City GeoLite2-ASN
GEOIPUPDATE_ACCOUNT_ID: ${GEOIPUPDATE_ACCOUNT_ID}
GEOIPUPDATE_LICENSE_KEY: ${GEOIPUPDATE_LICENSE_KEY}
GEOIPUPDATE_FREQUENCY: ${GEOIPUPDATE_FREQUENCY}
volumes:
- /srv/npmplus/goaccess/geoip:/usr/share/GeoIP
- /srv/npmplus/geoipupdate/log:/var/lib/geoipupdate
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
security_opt:
- no-new-privileges:true
networks:
- openappsec-net
# https://docs.openappsec.io/integrations/npmplus
openappsec-agent:
image: ghcr.io/openappsec/agent:latest
container_name: openappsec-agent
hostname: openappsec-agent
restart: always
depends_on:
- geoipupdate
environment:
AGENT_TOKEN: ${APPSEC_AGENT_TOKEN}
USER_EMAIL: ${APPSEC_USER_EMAIL}
AUTO_POLICY_LOAD: ${APPSEC_AUTO_POLICY_LOAD}
LOG_LEVEL: debug
TZ: ${TZ}
registered_server: NPMplus
command: /cp-nano-agent
volumes:
- shared-data:/data
- /srv/npmplus/appsec-localconfig:/ext/appsec
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
security_opt:
- no-new-privileges:true
networks:
- openappsec-net
# ttps://github.com/ZoeyVid/NPMplus
npmplus:
image: docker.io/zoeyvid/npmplus:latest
container_name: npmplus
hostname: npmplus
restart: always
depends_on:
- openappsec-agent
environment:
TZ: ${TZ}
ACME_EMAIL: ${ACME_EMAIL}
NGINX_HTTP_HOSTS: ${DOMAIN}
NGINX_HTTPS_HOSTS: ${DOMAIN}
NGINX_LOAD_OPENAPPSEC_ATTACHMENT_MODULE: true
LOGROTATE: true
LOGROTATIONS: 7
GOA: true
ports:
# Modify these ports as needed depending on service requirements
- 80:80
- 443:443
- 443:443/udp
- 81:81
volumes:
- shared-data:/data
- /srv/npmplus/localconfig:/ext/appsec
- /srv/npmplus/nginx:/opt/npmplus/nginx
- /srv/npmplus/logs:/opt/npmplus/logs
- /srv/npmplus/certs:/opt/npmplus/certs
- /srv/npmplus/goaccess:/opt/npmplus/goaccess
- /var/run/docker.sock:/var/run/docker.sock:ro
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
security_opt:
- no-new-privileges:true
networks:
- openappsec-net
networks:
openappsec-net:
driver: bridge
name: openappsec-net
attachable: true
driver_opts:
com.docker.network.bridge.default_bridge: "false"
com.docker.network.bridge.enable_icc: "true"
volumes:
shared-data:
Make sure you adjust the volumes as you require (I like putting them in the srv directory as that is what the damn thing is for)
In Linux, the /srv directory—short for “service”—is intended to hold data for services provided by the system. Think of it as a home for server-specific content, particularly when the machine is hosting things like web, FTP, or other network services.
As you can see, we need an environment file. Since I deploy via Portainer I have not referenced it in the docker compose file, but it is below
# ─────────────────────────────────────────────────────
# REQUIRED
# ─────────────────────────────────────────────────────
# Timezone for all containers
TZ=Australia/Perth
# Your open-appsec SaaS token
APPSEC_AGENT_TOKEN=[get this from the openappsec management portal]
# Domain served by NPMplus
DOMAIN=[your TLD]
# ACME (Let's Encrypt) email
ACME_EMAIL=[your registered email address]
# MAXMIND account information
GEOIPUPDATE_ACCOUNT_ID=[your ID number]
GEOIPUPDATE_LICENSE_KEY=[licence key - one for free]
GEOIPUPDATE_FREQUENCY=24
# The below is not used at this time - this code has been removed
# Cloudflare Tunnel
CF_TUNNEL_TOKEN=[your tunnel token ID]
CF_TUNNEL_NAME=[your tunnel name]
# ─────────────────────────────────────────────────────
# OPTIONAL
# ─────────────────────────────────────────────────────
# Email to register in open-appsec UI
APPSEC_USER_EMAIL=[your openappsec registered email address]
# Auto-reload local_policy.yaml from ./appsec-localconfig
APPSEC_AUTO_POLICY_LOAD=true
Don't forget to attach the frontend of other stacks to the network designated in the docker compose file so something like this.
# Extract of the ghost stack
# Not a complete so it wont work by itself and is supplied as an example
ghost:
# Do not alter this without updating the Tinybird Sync container as well
image: ghost:${GHOST_VERSION:-5-alpine}
container_name: ghost
hostname: ghost
restart: always
labels:
- "com.docker.compose.project=ghost"
- "com.docker.compose.service=ghost"
- "com.centurylinklabs.watchtower.enable=false"
- "dockflare.enable=false"
- "dockflare.hostname=ghost.${DOMAIN}"
- "dockflare.service=http://ghost:${GHOST_PORT}"
- "dockflare.no_tls_verify=true"
- "dockflare.originsrvname=ghost.${DOMAIN}"
- "dockflare.originsrvport=${GHOST_PORT}"
- "dockflare.originsrvproto=http"
# Expose the port externally
ports:
- ${GHOST_PORT}:2368
environment:
NODE_ENV: production
url: http://ghost.${DOMAIN}
# Database configuration
database__client: mysql
database__connection__host: db
database__connection__user: ${DATABASE_USER}
database__connection__password: ${DATABASE_PASSWORD}
database__connection__database: ghost
# Miscellaneous settings
enableDeveloperExperiments: ${ENABLE_DEVELOPER_EXPERIMENTS}
labs__ActivityPub: ${ENABLE_ACTIVITYPUB}
labs__trafficAnalytics: ${ENABLE_TRAFFIC_ANALYTICS}
# Mail configuration
mail__transport: ${MAIL_PROTOCOL}
mail__from: ${MAIL_ADDRESS}
mail__options__service: ${MAIL_SERVICE}
mail__options__host: ${MAIL_HOST}
mail__options__port: ${MAIL_PORT}
mail__options__secureConnection: ${MAIL_SECURITY}
mail__options__secure: ${MAIL_SECURITY}
mail__options__auth__user: ${MAIL_USER}
mail__options__auth__pass: ${MAIL_PASS}
# Tinybird configuration - not used in this stack
tinybird__tracker__endpoint: https://${DOMAIN}/.ghost/analytics/api/v1/page_hit
tinybird__adminToken: ${TINYBIRD_ADMIN_TOKEN}
tinybird__workspaceId: ${TINYBIRD_WORKSPACE_ID}
tinybird__tracker__datasource: analytics_events
tinybird__stats__endpoint: ${TINYBIRD_API_URL:-https://api.tinybird.co}
volumes:
- /srv/ghost/content:/var/lib/ghost/content:rw
- /srv/ghost/logs:/var/lib/ghost/logs:rw
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
db:
condition: service_healthy
activitypub:
condition: service_started
required: false
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "2368"]
interval: 10s
timeout: 5s
retries: 5
cap_add:
- SYS_TIME
security_opt:
- no-new-privileges:true
networks:
- ghost-net
- dockerflare-net
This works and will get you going. Note there is considerable configuration to be done afterwards and the password to get into the NPMplus management interface will be in the docker logs for that service.
Login straight away and reset your admin credentials or it will be a rm -rf command, after you delete the stack and try again.
#enoughsaid