From eff5e48713fe7a82332b8e19e75c46d0d4674edb Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:16:03 +0300 Subject: [PATCH 01/11] feat: add external monitoring mode and Grafana dashboard --- .env.example | 10 + Makefile | 37 ++- README.md | 63 ++-- config/promtail/promtail-config.yml | 3 +- docker-compose.monitoring.yml | 24 +- monitoring/dashboards/README.md | 54 ++++ monitoring/dashboards/nginx-security.json | 336 ++++++++++++++++++++++ 7 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 monitoring/dashboards/README.md create mode 100644 monitoring/dashboards/nginx-security.json diff --git a/.env.example b/.env.example index 7579d6e..a7b3bdc 100644 --- a/.env.example +++ b/.env.example @@ -71,3 +71,13 @@ GID=1000 # Grafana admin password GRAFANA_PASSWORD=your-secure-password + +# =========================================== +# External Monitoring (Optional) +# =========================================== +# Set LOKI_URL to connect Promtail to an external Loki instance +# instead of running a local Loki container. +# Use 'make start-monitoring-external' to start in external mode. + +# LOKI_URL=https://loki.provider.com/loki/api/v1/push +# LOKI_TENANT_ID=nginx-security diff --git a/Makefile b/Makefile index 4c2aeff..f360415 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring +.PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring start-monitoring-external health-monitoring-external IMAGE_NAME ?= neolab/nginx-security IMAGE_TAG ?= latest @@ -21,9 +21,11 @@ help: @echo " make restart - Restart all services" @echo "" @echo " Monitoring (optional add-on):" - @echo " make start-monitoring - Start monitoring stack" - @echo " make stop-monitoring - Stop monitoring stack" - @echo " make health-monitoring - Check monitoring health" + @echo " make start-monitoring - Start full monitoring stack (standalone)" + @echo " make start-monitoring-external - Start monitoring (external Loki/Prometheus)" + @echo " make stop-monitoring - Stop monitoring stack" + @echo " make health-monitoring - Check monitoring health (standalone)" + @echo " make health-monitoring-external - Check monitoring health (external)" @echo "" @echo " Testing:" @echo " make test - Run all tests" @@ -104,22 +106,45 @@ restart: # =========================================== start-monitoring: - docker compose -f docker-compose.monitoring.yml up -d + docker compose -f docker-compose.monitoring.yml --profile standalone up -d @echo "Connecting nginx-security to monitoring network..." @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true @echo "Waiting for monitoring services to start..." @sleep 15 @$(MAKE) health-monitoring +start-monitoring-external: + @if [ -z "$(LOKI_URL)" ]; then \ + echo "ERROR: LOKI_URL is not set."; \ + echo "Set it in .env or export it: export LOKI_URL=https://loki.provider.com/loki/api/v1/push"; \ + exit 1; \ + fi + @echo "Starting external monitoring mode..." + @echo " Loki URL: $(LOKI_URL)" + docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail + @echo "Connecting nginx-security to monitoring network..." + @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true + @echo "Waiting for monitoring services to start..." + @sleep 15 + @$(MAKE) health-monitoring-external + stop-monitoring: + docker compose -f docker-compose.monitoring.yml --profile standalone down docker compose -f docker-compose.monitoring.yml down health-monitoring: - @echo "Checking monitoring health..." + @echo "Checking monitoring health (standalone)..." @curl -sf http://localhost:9113/metrics > /dev/null 2>&1 && echo "nginx-exporter: OK" || echo "nginx-exporter: FAIL" @curl -sf http://localhost:6060/metrics > /dev/null 2>&1 && echo "crowdsec: OK" || echo "crowdsec: FAIL" @curl -sf http://localhost:3100/ready > /dev/null 2>&1 && echo "loki: OK" || echo "loki: FAIL" +health-monitoring-external: + @echo "Checking monitoring health (external)..." + @curl -sf http://localhost:9113/metrics > /dev/null 2>&1 && echo "nginx-exporter: OK" || echo "nginx-exporter: FAIL" + @curl -sf http://localhost:6060/metrics > /dev/null 2>&1 && echo "crowdsec: OK" || echo "crowdsec: FAIL" + @docker ps --format '{{.Names}}' | grep -q promtail && echo "promtail: RUNNING" || echo "promtail: NOT RUNNING" + @docker ps --format '{{.Names}}' | grep -q loki && echo "WARNING: loki is running (should not be in external mode)" || echo "loki: NOT RUNNING (expected)" + # =========================================== # Testing # =========================================== diff --git a/README.md b/README.md index 7120ec5..2857175 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,21 @@ make stop # Stop everything ## Add Monitoring (Optional) -The monitoring stack runs as separate containers alongside the main image: +The monitoring stack runs as separate containers alongside the main image. Two modes are available: + +### Standalone Mode (full stack) + +Deploys Loki + Promtail + CrowdSec + nginx-exporter locally: ```bash -# Start CrowdSec + Loki + Promtail + nginx-exporter -docker compose -f docker-compose.monitoring.yml up -d +# Start full monitoring stack +make start-monitoring # With Grafana dashboards -docker compose -f docker-compose.monitoring.yml --profile grafana up -d +docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d # With CrowdSec firewall bouncer (IP blocking) -docker compose -f docker-compose.monitoring.yml --profile bouncer up -d +docker compose -f docker-compose.monitoring.yml --profile standalone --profile bouncer up -d # Check health make health-monitoring @@ -106,25 +110,40 @@ make health-monitoring make stop-monitoring ``` -Monitoring services share logs via host-mounted `./logs/` volume. +### External Mode (connect to provider's Loki/Prometheus) -**Ports**: nginx-exporter (9113), CrowdSec (6060), Loki (3100), Grafana (3000) +When the provider already has Loki + Prometheus + Grafana deployed, use external mode. Only Promtail, nginx-exporter, and CrowdSec are started — Loki and Grafana are **not** deployed. -### Connect to Existing Grafana (Loki Data Source) +```bash +# Set external Loki URL in .env or export it +export LOKI_URL=https://loki.provider.com/loki/api/v1/push +export LOKI_TENANT_ID=nginx-security # optional, if multi-tenant Loki -If Cloudbankin already has Grafana, add Loki as a data source instead of running a separate Grafana: +# Start external monitoring +make start-monitoring-external -```bash -# Start monitoring without Grafana -docker compose -f docker-compose.monitoring.yml up -d +# Check health +make health-monitoring-external + +# Stop +make stop-monitoring ``` -In your existing Grafana, add data source: -- **Type**: Loki -- **URL**: `http://:3100` -- **Access**: Server +- **Promtail** sends logs to the external Loki at `LOKI_URL` +- **nginx-exporter** exposes metrics on port 9113 for the external Prometheus to scrape +- **CrowdSec** exposes metrics on port 6060 for the external Prometheus to scrape + +Add the Prometheus scrape targets from `monitoring/prometheus-scrape-config.yml` to the provider's Prometheus config. + +### Import Grafana Dashboard + +A pre-built dashboard is available at `monitoring/dashboards/nginx-security.json`. Import it into the provider's Grafana — see [monitoring/dashboards/README.md](monitoring/dashboards/README.md) for instructions. + +**Ports**: nginx-exporter (9113), CrowdSec (6060), Loki (3100, standalone only), Grafana (3000, standalone only) + +### Loki Queries -Example queries: +Example queries for Grafana: - All blocked requests: `{job="nginx", type="access"} |= "403"` - ModSecurity audit events: `{job="modsecurity"}` - Rate-limited requests: `{job="nginx", type="access"} |= "429"` @@ -279,8 +298,14 @@ cloud-native-nginx/ ├── logs/ # Shared log volume │ ├── nginx/ │ └── modsecurity/ -└── monitoring/ - └── prometheus-scrape-config.yml # Prometheus config template +├── monitoring/ +│ ├── prometheus-scrape-config.yml # Prometheus config template +│ └── dashboards/ +│ ├── nginx-security.json # Grafana dashboard (importable) +│ └── README.md # Dashboard import instructions +└── .github/ + └── workflows/ + └── ci.yml # CI/CD pipeline ``` ## Docs diff --git a/config/promtail/promtail-config.yml b/config/promtail/promtail-config.yml index 8db5082..02c7db7 100644 --- a/config/promtail/promtail-config.yml +++ b/config/promtail/promtail-config.yml @@ -6,7 +6,8 @@ positions: filename: /tmp/positions.yaml clients: - - url: http://loki:3100/loki/api/v1/push + - url: ${LOKI_URL} + tenant_id: ${LOKI_TENANT_ID} scrape_configs: # Nginx access logs diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml index 4ae7586..9d4103b 100644 --- a/docker-compose.monitoring.yml +++ b/docker-compose.monitoring.yml @@ -2,9 +2,14 @@ # Reads logs from shared host volumes (./logs/) # # Usage: -# docker compose -f docker-compose.monitoring.yml up -d -# docker compose -f docker-compose.monitoring.yml --profile bouncer up -d # with IP blocking -# docker compose -f docker-compose.monitoring.yml --profile grafana up -d # with dashboards +# Standalone (full stack): +# docker compose -f docker-compose.monitoring.yml --profile standalone up -d +# External (connect to provider's Loki/Prometheus): +# LOKI_URL=https://loki.provider.com/loki/api/v1/push docker compose -f docker-compose.monitoring.yml up -d +# With Grafana dashboards: +# docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d +# With CrowdSec bouncer: +# docker compose -f docker-compose.monitoring.yml --profile standalone --profile bouncer up -d services: # =========================================== @@ -68,11 +73,13 @@ services: memory: 128M # =========================================== - # Loki - Log Aggregation + # Loki - Log Aggregation (standalone mode only) # =========================================== loki: image: grafana/loki:3.3.2 container_name: loki + profiles: + - standalone ports: - "${LOKI_PORT:-3100}:3100" volumes: @@ -98,11 +105,12 @@ services: volumes: - ./config/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro - ./logs:/var/log/app:ro - command: -config.file=/etc/promtail/promtail-config.yml + command: -config.file=/etc/promtail/promtail-config.yml -config.expand-env=true + environment: + - LOKI_URL=${LOKI_URL:-http://loki:3100/loki/api/v1/push} + - LOKI_TENANT_ID=${LOKI_TENANT_ID:-} networks: - monitoring-net - depends_on: - - loki restart: unless-stopped # =========================================== @@ -148,8 +156,6 @@ services: - grafana-data:/var/lib/grafana networks: - monitoring-net - depends_on: - - loki restart: unless-stopped profiles: - grafana diff --git a/monitoring/dashboards/README.md b/monitoring/dashboards/README.md new file mode 100644 index 0000000..fe59beb --- /dev/null +++ b/monitoring/dashboards/README.md @@ -0,0 +1,54 @@ +# Grafana Dashboard — Nginx Security Stack + +Pre-built Grafana dashboard for monitoring the Nginx Security Stack. + +## Import Instructions + +### Via Grafana UI + +1. Open Grafana → **Dashboards** → **New** → **Import** +2. Click **Upload dashboard JSON file** and select `nginx-security.json` +3. Select your **Prometheus** and **Loki** data sources when prompted +4. Click **Import** + +### Via Grafana API + +```bash +curl -X POST http://admin:password@grafana-host:3000/api/dashboards/import \ + -H 'Content-Type: application/json' \ + -d @nginx-security.json +``` + +## Prerequisites + +The dashboard requires two data sources configured in Grafana: + +| Data Source | Type | Purpose | +|---|---|---| +| Prometheus | `prometheus` | nginx-exporter metrics (port 9113) + CrowdSec metrics (port 6060) | +| Loki | `loki` | Nginx access/error logs + ModSecurity audit logs | + +Add scrape targets to Prometheus using `monitoring/prometheus-scrape-config.yml`. + +## Dashboard Panels + +### Status Row +- **Nginx Status** — UP/DOWN indicator +- **Active Connections** — current active connections +- **Request Rate** — requests per second (5m average) +- **CrowdSec Active Bans** — current active ban decisions +- **CrowdSec Alerts** — alerts in last 24h +- **Attack Rate** — CrowdSec scenario triggers per second + +### Metrics Charts +- **Request Rate Over Time** — HTTP requests/s timeseries +- **Nginx Connections** — active, reading, writing, waiting connections +- **CrowdSec Active Decisions** — ban/captcha decisions by reason +- **CrowdSec Attack Rate** — scenario overflows and alerts rate + +### Log Panels +- **Nginx Access Logs** — all access log entries +- **Nginx Error Logs** — error log entries +- **ModSecurity Audit Logs** — WAF audit events (JSON) +- **Blocked Requests (403)** — requests blocked by WAF +- **Rate Limited Requests (429)** — requests blocked by rate limiter diff --git a/monitoring/dashboards/nginx-security.json b/monitoring/dashboards/nginx-security.json new file mode 100644 index 0000000..1ab1334 --- /dev/null +++ b/monitoring/dashboards/nginx-security.json @@ -0,0 +1,336 @@ +{ + "__inputs": [ + { + "name": "DS_PROMETHEUS", + "label": "Prometheus", + "description": "Prometheus data source for nginx-exporter and CrowdSec metrics", + "type": "datasource", + "pluginId": "prometheus", + "pluginName": "Prometheus" + }, + { + "name": "DS_LOKI", + "label": "Loki", + "description": "Loki data source for nginx and ModSecurity logs", + "type": "datasource", + "pluginId": "loki", + "pluginName": "Loki" + } + ], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "prometheus", "name": "Prometheus" }, + { "type": "datasource", "id": "loki", "name": "Loki" }, + { "type": "panel", "id": "timeseries", "name": "Time series" }, + { "type": "panel", "id": "stat", "name": "Stat" }, + { "type": "panel", "id": "logs", "name": "Logs" }, + { "type": "panel", "id": "table", "name": "Table" } + ], + "id": null, + "uid": "nginx-security-overview", + "title": "Nginx Security Stack", + "description": "Overview of Nginx WAF, CrowdSec IPS, and security events", + "tags": ["nginx", "security", "waf", "crowdsec", "modsecurity"], + "timezone": "browser", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "id": 1, + "title": "Nginx Status", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "nginx_up", + "legendFormat": "Nginx" + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } }, "type": "value" } + ], + "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } + }, + "overrides": [] + } + }, + { + "id": 2, + "title": "Active Connections", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "nginx_connections_active", + "legendFormat": "Active" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 100 }, { "color": "red", "value": 500 }] } + }, + "overrides": [] + } + }, + { + "id": 3, + "title": "Request Rate", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "rate(nginx_http_requests_total[5m])", + "legendFormat": "req/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 100 }, { "color": "red", "value": 500 }] } + }, + "overrides": [] + } + }, + { + "id": 4, + "title": "CrowdSec Active Bans", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(cs_active_decisions) or vector(0)", + "legendFormat": "Bans" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 20 }] } + }, + "overrides": [] + } + }, + { + "id": 5, + "title": "CrowdSec Alerts", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 16, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(increase(cs_alerts_total[24h])) or vector(0)", + "legendFormat": "24h" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 50 }] } + }, + "overrides": [] + } + }, + { + "id": 6, + "title": "Attack Rate (CrowdSec)", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 20, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "sum(rate(cs_bucket_overflowed_total[5m])) or vector(0)", + "legendFormat": "attacks/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.1 }, { "color": "red", "value": 1 }] } + }, + "overrides": [] + } + }, + { + "id": 10, + "title": "Request Rate Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { + "expr": "rate(nginx_http_requests_total[5m])", + "legendFormat": "Requests/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 20 } + }, + "overrides": [] + } + }, + { + "id": 11, + "title": "Nginx Connections", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "nginx_connections_active", "legendFormat": "Active" }, + { "expr": "nginx_connections_reading", "legendFormat": "Reading" }, + { "expr": "nginx_connections_writing", "legendFormat": "Writing" }, + { "expr": "nginx_connections_waiting", "legendFormat": "Waiting" } + ], + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 10 } + }, + "overrides": [] + } + }, + { + "id": 12, + "title": "CrowdSec Active Decisions", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "cs_active_decisions{action=\"ban\"}", "legendFormat": "Ban - {{reason}}" }, + { "expr": "cs_active_decisions{action=\"captcha\"}", "legendFormat": "Captcha - {{reason}}" } + ], + "fieldConfig": { + "defaults": { + "custom": { "drawStyle": "bars", "fillOpacity": 50 } + }, + "overrides": [] + } + }, + { + "id": 13, + "title": "CrowdSec Attack Rate", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, + "targets": [ + { "expr": "rate(cs_bucket_overflowed_total[5m])", "legendFormat": "{{name}}" }, + { "expr": "rate(cs_alerts_total[5m])", "legendFormat": "Alerts" } + ], + "fieldConfig": { + "defaults": { + "unit": "ops", + "custom": { "drawStyle": "line", "lineInterpolation": "smooth", "fillOpacity": 20 } + }, + "overrides": [] + } + }, + { + "id": 20, + "title": "Nginx Access Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 21, + "title": "Nginx Error Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"error\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 22, + "title": "ModSecurity Audit Logs", + "type": "logs", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"modsecurity\"}", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 23, + "title": "Blocked Requests (403)", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 36 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"} |= \"403\"", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + }, + { + "id": 24, + "title": "Rate Limited Requests (429)", + "type": "logs", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 36 }, + "datasource": { "type": "loki", "uid": "${DS_LOKI}" }, + "targets": [ + { + "expr": "{job=\"nginx\", type=\"access\"} |= \"429\"", + "legendFormat": "" + } + ], + "options": { + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": true, + "enableLogDetails": true + } + } + ], + "refresh": "30s", + "schemaVersion": 39, + "style": "dark", + "templating": { "list": [] }, + "time": { "from": "now-1h", "to": "now" }, + "timepicker": {}, + "version": 1 +} From 76e3da10b7765473c80269a212e2d2c3a2991aaa Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:20:05 +0300 Subject: [PATCH 02/11] ci: add GitHub Actions pipeline for Docker Hub build and publish --- .github/workflows/ci.yml | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..caec694 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,93 @@ +name: CI/CD + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + branches: [master] + +env: + IMAGE_NAME: viktorpalchynskyi/nginx-security + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + if: github.event_name == 'push' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image for testing + uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: ${{ env.IMAGE_NAME }}:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start test environment + run: | + docker network create test-net + docker run -d --name httpbin --network test-net kennethreitz/httpbin:latest + mkdir -p logs/nginx logs/modsecurity + docker run -d --name nginx-security \ + --network test-net \ + -e BACKEND=http://httpbin:80 \ + -p 8080:8080 \ + -v ${{ github.workspace }}/logs/nginx:/var/log/nginx \ + -v ${{ github.workspace }}/logs/modsecurity:/var/log/modsecurity \ + ${{ env.IMAGE_NAME }}:test + echo "Waiting for nginx to start..." + sleep 15 + curl -sf http://localhost:8080/healthz || (docker logs nginx-security && exit 1) + + - name: Run security tests + run: ./scripts/test-security.sh localhost:8080 http + + - name: Run false positive tests + run: ./scripts/test-false-positives.sh localhost:8080 http + + - name: Stop test environment + if: always() + run: | + docker stop nginx-security httpbin 2>/dev/null || true + docker rm nginx-security httpbin 2>/dev/null || true + docker network rm test-net 2>/dev/null || true + + - name: Login to Docker Hub + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + if: github.event_name == 'push' + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + if: github.event_name == 'push' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max From 4a971360afb54470c2a1a375313f1d01652b85b6 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:40:14 +0300 Subject: [PATCH 03/11] fix: remove log volume mounts in CI to avoid permission denied --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caec694..b1fc658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,10 @@ jobs: run: | docker network create test-net docker run -d --name httpbin --network test-net kennethreitz/httpbin:latest - mkdir -p logs/nginx logs/modsecurity docker run -d --name nginx-security \ --network test-net \ -e BACKEND=http://httpbin:80 \ -p 8080:8080 \ - -v ${{ github.workspace }}/logs/nginx:/var/log/nginx \ - -v ${{ github.workspace }}/logs/modsecurity:/var/log/modsecurity \ ${{ env.IMAGE_NAME }}:test echo "Waiting for nginx to start..." sleep 15 From 7b137fe5034f35e8fe2610e1dd3fc525b50b8c3a Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:45:01 +0300 Subject: [PATCH 04/11] fix: load .env in Makefile for external monitoring LOKI_URL validation --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index f360415..3aebd16 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +-include .env +export + .PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring start-monitoring-external health-monitoring-external IMAGE_NAME ?= neolab/nginx-security From 018a54f788c9e9a1862aa551af6da19302c3d5f4 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:50:57 +0300 Subject: [PATCH 05/11] fix: skip GHA cache write on fork PRs to prevent permission errors --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1fc658..a1c5a46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: load: true tags: ${{ env.IMAGE_NAME }}:test cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: ${{ github.event.pull_request.head.repo.fork != true && 'type=gha,mode=max' || '' }} - name: Start test environment run: | From 89757b25036a77f7614fb0e8117f99d10425b8c3 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:52:02 +0300 Subject: [PATCH 06/11] fix: mask credentials in LOKI_URL output --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3aebd16..5a08860 100644 --- a/Makefile +++ b/Makefile @@ -123,7 +123,7 @@ start-monitoring-external: exit 1; \ fi @echo "Starting external monitoring mode..." - @echo " Loki URL: $(LOKI_URL)" + @echo " Loki URL: $$(echo '$(LOKI_URL)' | sed -E 's|://[^@]*@|://***@|')" docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail @echo "Connecting nginx-security to monitoring network..." @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true From 5a78e4061cddd4b337e81bd3f9182242ff47d798 Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 17 Feb 2026 16:53:11 +0300 Subject: [PATCH 07/11] fix: stop standalone loki/grafana before starting external mode --- Makefile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Makefile b/Makefile index 5a08860..ec3d05d 100644 --- a/Makefile +++ b/Makefile @@ -124,6 +124,8 @@ start-monitoring-external: fi @echo "Starting external monitoring mode..." @echo " Loki URL: $$(echo '$(LOKI_URL)' | sed -E 's|://[^@]*@|://***@|')" + @docker compose -f docker-compose.monitoring.yml stop loki grafana 2>/dev/null || true + @docker compose -f docker-compose.monitoring.yml rm -f loki grafana 2>/dev/null || true docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail @echo "Connecting nginx-security to monitoring network..." @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true From e90355bf02909c3edc0004ce14dd997b6a360504 Mon Sep 17 00:00:00 2001 From: Viktor Date: Wed, 18 Feb 2026 12:26:31 +0300 Subject: [PATCH 08/11] ci: split CI/CD pipeline into separate verify and publish workflows --- .github/workflows/publish.yml | 49 ++++++++++++++++++++++++ .github/workflows/{ci.yml => verify.yml} | 40 +------------------ 2 files changed, 51 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/publish.yml rename .github/workflows/{ci.yml => verify.yml} (58%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..3c741e2 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,49 @@ +name: Publish + +on: + push: + branches: [master] + tags: ['v*'] + +env: + IMAGE_NAME: viktorpalchynskyi/nginx-security + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=sha- + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/ci.yml b/.github/workflows/verify.yml similarity index 58% rename from .github/workflows/ci.yml rename to .github/workflows/verify.yml index a1c5a46..41c32d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/verify.yml @@ -1,9 +1,8 @@ -name: CI/CD +name: Verify on: push: branches: [master] - tags: ['v*'] pull_request: branches: [master] @@ -11,15 +10,11 @@ env: IMAGE_NAME: viktorpalchynskyi/nginx-security jobs: - ci: + verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up QEMU - if: github.event_name == 'push' - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -57,34 +52,3 @@ jobs: docker stop nginx-security httpbin 2>/dev/null || true docker rm nginx-security httpbin 2>/dev/null || true docker network rm test-net 2>/dev/null || true - - - name: Login to Docker Hub - if: github.event_name == 'push' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - if: github.event_name == 'push' - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix=sha- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - - - name: Build and push - if: github.event_name == 'push' - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max From 52cd8420d5d4ce2a1a551e98b4f3e8bbe3ad66ea Mon Sep 17 00:00:00 2001 From: Viktor Date: Wed, 18 Feb 2026 12:30:09 +0300 Subject: [PATCH 09/11] docs: rewrite README with Docker Hub usage instructions --- README.md | 365 ++++++++++++++++++------------------------------------ 1 file changed, 118 insertions(+), 247 deletions(-) diff --git a/README.md b/README.md index 2857175..8eb8d05 100644 --- a/README.md +++ b/README.md @@ -1,314 +1,185 @@ # Nginx Security Stack -Production-ready security layer for web applications. Single Docker image with WAF, rate limiting, and OWASP protection. +Reverse proxy with built-in WAF, rate limiting, and OWASP protection. Single Docker image — put it in front of your backend. -**Features**: -- OWASP ModSecurity CRS v4 (WAF with 15 custom rules) -- Rate limiting per IP (auth: 5 req/min, API: 100 req/min) -- SQL injection, XSS, SSRF, path traversal protection -- Scanner/bot detection (sqlmap, nikto, nmap, burpsuite) -- HTTPS backend proxy support (for backends on HTTPS:8443) -- JSON audit logging for all blocked requests -- Optional monitoring: CrowdSec IPS, Loki, Grafana +## 1. Run -## Prerequisites - -- Docker 20.10+ -- 512MB RAM minimum (1GB recommended with monitoring) -- SSL certificates (for HTTPS termination, optional) - -## Quick Start +The only required setting is `BACKEND` — the URL of your application. ```bash -# 1. Build the image -make build - -# 2. Run with your backend docker run -d --name nginx-security \ -e BACKEND=http://your-app:3000 \ -p 80:8080 \ - neolab/nginx-security - -# 3. Verify -curl http://localhost/healthz + viktorpalchynskyi/nginx-security ``` -### Cloudbankin Deployment (HTTPS backend) +Check that it works: ```bash -docker run -d --name nginx-security \ - -e BACKEND=https://paapapayperf.uat.cloudbankin.com:8443 \ - -e PROXY_SSL=on \ - -e PROXY_SSL_VERIFY=off \ - -p 80:8080 -p 443:8443 \ - neolab/nginx-security +curl http://localhost/healthz ``` -### With SSL Termination +Done. WAF and rate limiting are enabled by default. + +## 2. Add HTTPS (optional) + +Provide your SSL certificate and key: ```bash docker run -d --name nginx-security \ -e BACKEND=http://your-app:3000 \ - -p 80:8080 -p 443:8443 \ + -e NGINX_ALWAYS_TLS_REDIRECT=on \ -v /path/to/fullchain.pem:/etc/nginx/certs/fullchain.pem:ro \ -v /path/to/privkey.pem:/etc/nginx/certs/privkey.pem:ro \ - neolab/nginx-security + -p 80:8080 -p 443:8443 \ + viktorpalchynskyi/nginx-security ``` -## Development Mode - -Uses docker-compose with httpbin test backend: +If your backend is on HTTPS, add: ```bash -cp .env.example .env -make start # Starts nginx-waf + crowdsec + httpbin -make test # Run security + false positive tests -make stop # Stop everything + -e BACKEND=https://your-app:8443 \ + -e PROXY_SSL=on \ ``` -## Environment Variables +## 3. Add Monitoring with Grafana (optional) -| Variable | Default | Description | -|----------|---------|-------------| -| `BACKEND` | `http://localhost:80` | Backend URL to proxy to | -| `PORT` | `8080` | HTTP listen port | -| `SSL_PORT` | `8443` | HTTPS listen port | -| `SERVER_NAME` | `_` | Nginx server_name | -| `MODSEC_RULE_ENGINE` | `On` | WAF mode: `On`, `DetectionOnly`, `Off` | -| `PARANOIA` | `1` | OWASP CRS paranoia level (1-4, higher = stricter) | -| `ANOMALY_INBOUND` | `5` | Inbound anomaly score threshold | -| `ANOMALY_OUTBOUND` | `4` | Outbound anomaly score threshold | -| `PROXY_TIMEOUT` | `60` | Backend proxy timeout (seconds) | -| `PROXY_SSL` | `off` | Enable SSL to backend (`on`/`off`) | -| `PROXY_SSL_VERIFY` | `off` | Verify backend SSL cert (`on`/`off`) | -| `PROXY_SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed SSL protocols to backend | -| `SET_REAL_IP_FROM` | — | Trusted proxy CIDR (for LB/CDN) | -| `REAL_IP_HEADER` | `X-Forwarded-For` | Header containing real client IP | - -## Add Monitoring (Optional) - -The monitoring stack runs as separate containers alongside the main image. Two modes are available: +Starts Loki + Promtail + Grafana + nginx-exporter + CrowdSec alongside the nginx-security container. -### Standalone Mode (full stack) - -Deploys Loki + Promtail + CrowdSec + nginx-exporter locally: +**With make:** ```bash -# Start full monitoring stack make start-monitoring - -# With Grafana dashboards -docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d - -# With CrowdSec firewall bouncer (IP blocking) -docker compose -f docker-compose.monitoring.yml --profile standalone --profile bouncer up -d - -# Check health -make health-monitoring - -# Stop -make stop-monitoring ``` -### External Mode (connect to provider's Loki/Prometheus) - -When the provider already has Loki + Prometheus + Grafana deployed, use external mode. Only Promtail, nginx-exporter, and CrowdSec are started — Loki and Grafana are **not** deployed. +**With docker compose:** ```bash -# Set external Loki URL in .env or export it -export LOKI_URL=https://loki.provider.com/loki/api/v1/push -export LOKI_TENANT_ID=nginx-security # optional, if multi-tenant Loki - -# Start external monitoring -make start-monitoring-external - -# Check health -make health-monitoring-external - -# Stop -make stop-monitoring +docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d +docker network connect nginx-security-monitoring nginx-security ``` -- **Promtail** sends logs to the external Loki at `LOKI_URL` -- **nginx-exporter** exposes metrics on port 9113 for the external Prometheus to scrape -- **CrowdSec** exposes metrics on port 6060 for the external Prometheus to scrape +After startup: -Add the Prometheus scrape targets from `monitoring/prometheus-scrape-config.yml` to the provider's Prometheus config. +1. Open Grafana at `http://localhost:3000` (login: `admin` / `admin`) +2. Go to **Dashboards** → **Import** → upload [`monitoring/dashboards/nginx-security.json`](monitoring/dashboards/nginx-security.json) +3. Select **Prometheus** and **Loki** data sources when prompted -### Import Grafana Dashboard - -A pre-built dashboard is available at `monitoring/dashboards/nginx-security.json`. Import it into the provider's Grafana — see [monitoring/dashboards/README.md](monitoring/dashboards/README.md) for instructions. - -**Ports**: nginx-exporter (9113), CrowdSec (6060), Loki (3100, standalone only), Grafana (3000, standalone only) - -### Loki Queries - -Example queries for Grafana: -- All blocked requests: `{job="nginx", type="access"} |= "403"` -- ModSecurity audit events: `{job="modsecurity"}` -- Rate-limited requests: `{job="nginx", type="access"} |= "429"` - -## Configuration Reference - -### Paranoia Levels - -| Level | Description | Use Case | -|-------|-------------|----------| -| 1 | Standard detection, minimal false positives | Production (default) | -| 2 | Extended detection, some false positives | Sensitive APIs | -| 3 | Aggressive detection, more false positives | High-security | -| 4 | Maximum detection, many false positives | Testing/audit only | - -### Rate Limiting - -| Zone | Rate | Burst | Endpoints | -|------|------|-------|-----------| -| `auth` | 5 req/min | 3 | Login, register, verify, OTP, authentication | -| `api` | 100 req/min | 20 | `/api/*`, `/graphql` | - -Auth rate limiting matches Cloudbankin URL patterns: -- `/api/auth/login` -- `/cloudbankin/api/v1/public/los/login` -- `/cloudbankin/api/v1/authentication` -- `/cloudbankin/api/v1/public/los/borrower-login-verify` - -### Custom WAF Rules (15 rules) - -- Advanced SQL injection detection -- XSS (script, javascript:, event handlers) -- Command injection with path traversal -- Path traversal (plain, URL-encoded, double-encoded) -- Sensitive file access (.env, .git, /etc/passwd) -- Null byte injection -- Scanner/bot User-Agent detection -- SSRF protection (internal IPs, cloud metadata) -- HTTP method restriction (GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD) - -## Verification +Check health: ```bash -# Health check -curl http://localhost:8080/healthz - -# WAF test (should return 403) -curl "http://localhost:8080/?id=1'+OR+'1'='1" +make health-monitoring +``` -# Rate limit test (6th request should return 429) -for i in {1..6}; do - curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:8080/api/auth/login -done +Stop: -# Run full test suite (requires development mode) -make test-security -make test-false-pos +```bash +make stop-monitoring ``` -## Troubleshooting +### Connect to an Existing Grafana -### False Positives (Legitimate Requests Blocked) +If you already have Grafana, Prometheus, and Loki — use external mode. Only Promtail, nginx-exporter, and CrowdSec are started. -```bash -# 1. Check which rule is blocking -docker exec nginx-waf tail -100 /var/log/modsecurity/audit.log | jq '.transaction.messages[].ruleId' +**With make:** -# 2. Add exclusion to config/modsecurity/exclusions.conf: -# SecRuleRemoveById +```bash +# Set your Loki URL in .env or export it +export LOKI_URL=https://your-loki:3100/loki/api/v1/push -# 3. Rebuild image -make build +make start-monitoring-external ``` -### Emergency WAF Disable +**With docker compose:** ```bash -# Switch to detection-only mode (logs but doesn't block) -docker run -d --name nginx-security \ - -e BACKEND=http://your-app:3000 \ - -e MODSEC_RULE_ENGINE=DetectionOnly \ - -p 80:8080 \ - neolab/nginx-security +export LOKI_URL=https://your-loki:3100/loki/api/v1/push -# Disable WAF completely -# -e MODSEC_RULE_ENGINE=Off +docker compose -f docker-compose.monitoring.yml up -d nginx-exporter crowdsec promtail +docker network connect nginx-security-monitoring nginx-security ``` -### Load Balancer / CDN Configuration - -When behind a load balancer, set these to get real client IPs for rate limiting: +Then add scrape targets to your Prometheus (see [`monitoring/prometheus-scrape-config.yml`](monitoring/prometheus-scrape-config.yml)): -```bash -docker run -d --name nginx-security \ - -e BACKEND=http://your-app:3000 \ - -e SET_REAL_IP_FROM=10.0.0.0/8 \ - -e REAL_IP_HEADER=X-Forwarded-For \ - -p 80:8080 \ - neolab/nginx-security +```yaml +scrape_configs: + - job_name: 'nginx-waf' + static_configs: + - targets: [':9113'] + - job_name: 'crowdsec' + static_configs: + - targets: [':6060'] ``` -## Architecture +Import the dashboard [`monitoring/dashboards/nginx-security.json`](monitoring/dashboards/nginx-security.json) into your Grafana. + +### Useful Loki Queries ``` - ┌─────────────────────────────────────────┐ - │ neolab/nginx-security │ - Client ──────► │ Nginx + ModSecurity CRS v4 │ ──────► Backend - (HTTP/HTTPS) │ ┌─────────┐ ┌──────────┐ ┌────────┐ │ (HTTP/HTTPS) - │ │ Rate │─►│ WAF │─►│ Proxy │ │ - │ │ Limiter │ │(15 rules)│ │ │ │ - │ └─────────┘ └──────────┘ └────────┘ │ - └────────────────────┬────────────────────┘ - │ logs (volume mount) - ┌────────────────────┴────────────────────┐ - │ Monitoring (optional, separate) │ - │ CrowdSec │ Loki │ Promtail │ Grafana │ - └─────────────────────────────────────────┘ +{job="nginx", type="access"} |= "403" # Blocked by WAF +{job="nginx", type="access"} |= "429" # Rate limited +{job="modsecurity"} # WAF audit events ``` -## File Structure +## Reference -``` -cloud-native-nginx/ -├── Dockerfile # Single-image build -├── .dockerignore # Build context exclusions -├── Makefile # All commands -├── docker-compose.yml # Development env (with httpbin) -├── docker-compose.monitoring.yml # Monitoring stack (optional) -├── .env.example # Environment variable template -│ -├── config/ -│ ├── nginx/ -│ │ └── default.conf.template # Nginx config (rate limiting, proxy) -│ ├── modsecurity/ -│ │ ├── custom-rules.conf # 15 custom WAF rules -│ │ └── exclusions.conf # False positive exclusions -│ ├── crowdsec/ # CrowdSec IPS config -│ ├── loki/ -│ │ └── loki-config.yml # Log aggregation config -│ └── promtail/ -│ └── promtail-config.yml # Log collector config -│ -├── scripts/ -│ ├── test-security.sh # Attack tests (SQLi, XSS, SSRF, etc.) -│ └── test-false-positives.sh # Legitimate traffic tests -│ -├── docs/ -│ ├── QUICK_REFERENCE.md # Command reference -│ └── EMERGENCY.md # Emergency procedures -│ -├── certs/ # SSL certificates (not in image) -├── logs/ # Shared log volume -│ ├── nginx/ -│ └── modsecurity/ -├── monitoring/ -│ ├── prometheus-scrape-config.yml # Prometheus config template -│ └── dashboards/ -│ ├── nginx-security.json # Grafana dashboard (importable) -│ └── README.md # Dashboard import instructions -└── .github/ - └── workflows/ - └── ci.yml # CI/CD pipeline -``` +### Environment Variables -## Docs +All variables have sensible defaults. You only need to set `BACKEND`. -- [Quick Reference](docs/QUICK_REFERENCE.md) - All commands and manual operations -- [Emergency Procedures](docs/EMERGENCY.md) - Incident response playbook +| Variable | Default | Description | +|----------|---------|-------------| +| **Core** | | | +| `BACKEND` | `http://localhost:80` | **Required.** Backend URL to proxy to | +| `PORT` | `8080` | HTTP listen port | +| `SSL_PORT` | `8443` | HTTPS listen port | +| `SERVER_NAME` | `_` | Nginx server_name | +| `PROXY_TIMEOUT` | `60` | Backend proxy timeout (seconds) | +| `SERVER_TOKENS` | `off` | Show nginx version in headers (`on`/`off`) | +| **WAF** | | | +| `MODSEC_RULE_ENGINE` | `On` | `On` = blocking, `DetectionOnly` = logging only, `Off` = disabled | +| `PARANOIA` | `1` | OWASP CRS paranoia level 1-4 (higher = stricter) | +| `ANOMALY_INBOUND` | `5` | Inbound anomaly score threshold | +| `ANOMALY_OUTBOUND` | `4` | Outbound anomaly score threshold | +| `MODSEC_AUDIT_LOG` | `/var/log/modsecurity/audit.log` | Path to WAF audit log | +| `MODSEC_AUDIT_LOG_FORMAT` | `JSON` | Audit log format (`JSON` or `Native`) | +| **HTTPS backend** | | | +| `PROXY_SSL` | `off` | Enable SSL to backend (`on`/`off`) | +| `PROXY_SSL_VERIFY` | `off` | Verify backend SSL certificate (`on`/`off`) | +| `PROXY_SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed SSL protocols to backend | +| **Real IP** | | | +| `SET_REAL_IP_FROM` | — | Trusted proxy CIDR for LB/CDN (e.g. `10.0.0.0/8`) | +| `REAL_IP_HEADER` | `X-Forwarded-For` | Header containing real client IP | +| **SSL termination** | | | +| `SSL_CERT_FILE` | `/etc/nginx/certs/fullchain.pem` | Path to SSL certificate | +| `SSL_CERT_KEY_FILE` | `/etc/nginx/certs/privkey.pem` | Path to SSL private key | +| `SSL_PROTOCOLS` | `TLSv1.2 TLSv1.3` | Allowed TLS protocols | +| `SSL_CIPHERS` | *(base image default)* | Allowed SSL ciphers | +| `SSL_PREFER_CIPHERS` | `on` | Prefer server ciphers over client (`on`/`off`) | +| `SSL_DH_BITS` | `2048` | DH parameters size (`2048` or `4096`) | +| `SSL_OCSP_STAPLING` | `on` | OCSP stapling (`on`/`off`) | +| `SSL_VERIFY` | `off` | Verify client certificate (`on`/`off`) | +| `SSL_VERIFY_DEPTH` | `1` | Client certificate chain verification depth | +| `NGINX_ALWAYS_TLS_REDIRECT` | `off` | Redirect all HTTP to HTTPS (`on`/`off`) | + +### Volumes + +All optional. The image works out of the box. + +| Container Path | What It Does | +|----------------|-------------| +| `/etc/nginx/certs/` | SSL certificates for HTTPS termination | +| `/var/log/nginx/` | Nginx access and error logs | +| `/var/log/modsecurity/` | WAF audit logs (JSON) | +| `/etc/nginx/templates/conf.d/default.conf.template` | Custom nginx config (override rate limits, locations) | +| `/etc/modsecurity.d/owasp-crs/rules/RESPONSE-999-CUSTOM.conf` | Custom WAF rules | +| `/etc/modsecurity.d/owasp-crs/rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` | WAF rule exclusions (false positive fixes) | + +### Ports + +| Port | Description | +|------|-------------| +| 8080 | HTTP | +| 8443 | HTTPS | +| 9113 | nginx-exporter metrics (sidecar) | +| 6060 | CrowdSec metrics (sidecar) | From 538222df73ea288122bb2e93d4c2a97e18db0cfa Mon Sep 17 00:00:00 2001 From: Viktor Date: Wed, 18 Feb 2026 15:23:02 +0300 Subject: [PATCH 10/11] fix: add grafana profile to start-monitoring target --- Makefile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index ec3d05d..6e13403 100644 --- a/Makefile +++ b/Makefile @@ -109,7 +109,7 @@ restart: # =========================================== start-monitoring: - docker compose -f docker-compose.monitoring.yml --profile standalone up -d + docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana up -d @echo "Connecting nginx-security to monitoring network..." @docker network connect nginx-security-monitoring $(CONTAINER_NAME) 2>/dev/null || true @echo "Waiting for monitoring services to start..." @@ -134,7 +134,7 @@ start-monitoring-external: @$(MAKE) health-monitoring-external stop-monitoring: - docker compose -f docker-compose.monitoring.yml --profile standalone down + docker compose -f docker-compose.monitoring.yml --profile standalone --profile grafana down docker compose -f docker-compose.monitoring.yml down health-monitoring: @@ -142,6 +142,7 @@ health-monitoring: @curl -sf http://localhost:9113/metrics > /dev/null 2>&1 && echo "nginx-exporter: OK" || echo "nginx-exporter: FAIL" @curl -sf http://localhost:6060/metrics > /dev/null 2>&1 && echo "crowdsec: OK" || echo "crowdsec: FAIL" @curl -sf http://localhost:3100/ready > /dev/null 2>&1 && echo "loki: OK" || echo "loki: FAIL" + @curl -sf http://localhost:3000/api/health > /dev/null 2>&1 && echo "grafana: OK" || echo "grafana: FAIL" health-monitoring-external: @echo "Checking monitoring health (external)..." From 07ee6c1bc4d804ce21ff20b03e2a4d973cc0eb95 Mon Sep 17 00:00:00 2001 From: Viktor Date: Wed, 18 Feb 2026 15:23:33 +0300 Subject: [PATCH 11/11] fix: remove .env include from Makefile to prevent dollar-sign corruption --- Makefile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Makefile b/Makefile index 6e13403..855c879 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,3 @@ --include .env -export - .PHONY: help build run run-ssl stop-standalone health-standalone start stop restart test test-security test-false-pos logs logs-audit status bans unban-all health clean start-monitoring stop-monitoring health-monitoring start-monitoring-external health-monitoring-external IMAGE_NAME ?= neolab/nginx-security