A real-world implementation with Wazuh, Graylog, MongoDB, Grafana, Nginx, OAuth2-Proxy, Redis, AI and SSO — no licenses, no vendor lock-in.
Press enter or click to view image in full size
Managing IT infrastructure security without commercial tools is entirely possible. This documenting the implementation of a complete open source security platform, deployed in production.
This is not a generic tutorial. It’s a guide based on real decisions, and an architecture that runs in production today.
What problem does this platform solve?
In any organization, visibility into what’s happening on servers tends to be fragmented: logs are scattered across different locations, alerts don’t arrive in real time, and each system has its own credentials. The result is an IT team that reacts instead of anticipating.
This platform centralizes three critical capabilities:
- Threat detection — Wazuh monitors security events, file integrity, and system behavior in real time.
- Log management — Graylog receives, indexes, alerts, and allows querying all infrastructure logs from a single point.
- Operational visualization — Grafana delivers real-time dashboards.
All of this is accessible through a single sign-on using the corporate account, thanks to OAuth2-Proxy.
The architecture in brief
The solution is built on five layers:
Detection layer: Wazuh acts as the SIEM/XDR engine. It analyzes events, correlates alerts, and generates notifications. These events/alerts are sent to Graylog via Fluent Bit.
Transport layer: Fluent Bit reads Wazuh events and forwards them to Graylog over RAW TCP. It’s lightweight, efficient, and highly configurable.
Log management layer: Graylog parses the JSON data, indexes all events, enables natural language searches, and exposes an Bridge that we later integrate with Claude via MCP.
Visualization layer: Grafana connects to Wazuh as a datasource and presents data in real-time operational dashboards.
Access layer: Nginx acts as a reverse proxy with TLS ar HTTP header-based authentication. OAuth2-Proxy validates user identity against OIDC and propagates it to each application. Redis stores the sessions.
Technology stack
The entire stack is open source and runs on a single Linux server:
- Ubuntu 26.04 LTS
- Wazuh v4.14.5
- Graylog v7.1.0
- MongoDB v7.0
- Grafana v13.0.1
- OAuth2-Proxy v7.15.2
- Fluent Bit v5.0.5
- Nginx v1.28.3
- Redis v8.0.5
Hardware: Minimum 8 CPU cores, 16 GB RAM
Storage: Dedicated LVM volumes (OS, data and logs separated)
Why this design?
Two architectural decisions deserve explanation:
Separate LVM volumes. The operating system, application data, and logs live on independent partitions. If logs grow out of control, they don’t affect the OS or application data. Scaling log storage is as simple as expanding the corresponding volume.
A single authentication point. Instead of managing users and passwords separately, OAuth2-Proxy delegates all authentication to IdP. The user logs in once and accesses all three systems. Local credentials are eliminated from the lifecycle.
What’s coming in the next articles
This series covers the complete implementation, component by component:
- Introduction and architecture ← you are here
- Wazuh — SIEM/XDR
- MongoDB — Graylog’s DB
- Graylog — Log management, data input, alerts
- Fluent Bit — Log shipper
- Grafana — Real-Time Operational Dashboards
- OAuth2-Proxy — Single Sign-On with IdP (Identity Provider)
- Redis — Session Persistence and Storage
- Nginx — Reverse Proxy with TLS and Header-Based Authentication
- Troubleshooting Guide
- Authentication — Four steps: SSO and intregation Graylog with Wazuh
- Graylog Ingest — Configuring Data Ingest from Fluent Bit
- Graylog MCP + Claude — Querying logs in natural language with AI
- Final architecture, evidences, and conclusions
Each article includes the exact commands used in production.
Recommendation: Create a working directory. In some sections, we jump to different directories cd; after each step, return to the directory.
Wazuh: SIEM/XDR
Part 2
This article covers the foundation of the entire stack: the server where the platform lives, and the installation and configuration of Wazuh — the SIEM/XDR engine that detects and correlates security events in real time.
The server platform
Before installing any component, it’s worth explaining the storage decision. We use separate LVM volumes for the operating system, application data, and logs:
Volume Size Path Operating system 60 GB / Data 150 GB /data Logs 20 GB /log Swap 4 GB — estimate sizes based on own infrastructure.
Why this separation? If logs grow out of control — and they will — they don’t affect the operating system or application data. Scaling log storage is as simple as expanding the /log volume without touching anything else. The same logic applies to application data in /data.
Wazuh: the detection engine
Wazuh is an open source SIEM (Security Information and Event Management) and XDR (Extended Detection and Response) platform. In practical terms, it continuously monitors system events, correlates alerts, detects file integrity issues, access anomalies, and much more.
In this implementation, Wazuh serves two roles:
- Detection — analyzes operating system and service events.
- Export — generates alerts in JSON format that Fluent Bit forwards to Graylog.
Installation
Wazuh’s installation is notable for its simplicity: a single script handles the entire stack (Wazuh Manager, Indexer, and Dashboard).
curl -sO https://packages.wazuh.com/4.14/wazuh-install.sh && bash ./wazuh-install.sh -aInfo: Despite the compatibility information, it works without problems with Ubuntu 26.04.
“The recommended systems are: Red Hat Enterprise Linux 7, 8, 9; CentOS 7, 8; Amazon Linux 2; Amazon Linux 2023; Ubuntu 16.04, 18.04, 20.04, 22.04; Rocky Linux 9.4”
Test environments: If the server doesn’t meet the minimum hardware requirements (4 GB RAM, 2 CPU cores), add the
-iargument to skip the validation.
Configuration
The Wazuh installer places its data in default paths /var/lib/wazuh-indexer, /var/ossec/logs. We need to mount bind them to our LVM volumes while keeping the original paths functional.
# Stop servicessystemctl stop wazuh-indexer wazuh-dashboard wazuh-manager filebeat
Filebeat is included in the Wazuh installation but we’ll replace it with Fluent Bit, which is lighter and more flexible for forwarding events to Graylog.
# Disable Filebeatsystemctl disable filebeat
# Create directory structuremkdir -p /data/wazuh-indexer/lib /log/wazuh-indexer /data/wazuh/ossec/logs
# Assign permissions to the service user and fileschown wazuh-indexer:wazuh-indexer /data/wazuh-indexer/lib /log/wazuh-indexer
chown wazuh:wazuh /data/wazuh/ossec/logs
chmod 770 /data/wazuh/ossec/logs
# Move existing contentmv /var/lib/wazuh-indexer/* /data/wazuh-indexer/lib/
mv /var/ossec/logs/* /data/wazuh/ossec/logs/
Mount with bind — preserving original paths
The key is using bind mounts: services continue using their default paths, but the actual storage is on the LVM volumes. This avoids modifying Wazuh’s internal configuration.
Evaluate:
With “nofails” the server will start even if the mounts fail, but the services will fail.
Omitting “nofails” will start in emergency mode.
echo "/data/wazuh/ossec/logs /var/ossec/logs none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/data/wazuh-indexer/lib /var/lib/wazuh-indexer none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/log/wazuh-indexer /var/log/wazuh-indexer none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -aCompatibility adjustment for Graylog
Wazuh Indexer uses OpenSearch with an option that, by default, forces another version for Filebeat compatibility.
# /etc/wazuh-indexer/opensearch.yml - Comment out#compatibility.override_main_response_version: true
About the Dashboard warning: When connecting Graylog, the Wazuh Dashboard may display a warning about
wazuh-alerts-*index patterns. This is a cosmetic warning that does not affect functionality — it can be safely ignored.Expected scenario: Filebeat cannot send data to the indexer, and alerts will not appear in the Wazuh dashboard.
Adjusting the Dashboard for Nginx
The Wazuh Dashboard will listen on 127.0.0.1:8080 instead of the default port, since Nginx will act as a reverse proxy with TLS on port 443.
# /etc/wazuh-dashboard/opensearch_dashboards.ymlserver.host: 127.0.0.1
server.port: 8080
Credentials and backup
After installation, Wazuh generates a wazuh-install-files.tar file containing certificates, the root CA, and passwords. It's critical to extract and back it up immediately.
tar -xvf wazuh-install-files.tarThe wazuh-passwords.txt file inside contains all automatically generated passwords. Protecting it is mandatory.
MongoDB: Graylog’s DB
Part 3
MongoDB is the database Graylog uses to store its internal configuration: streams, alerts, dashboards, users, and metadata. It does not store the logs themselves — that’s handled by OpenSearch/ElasticSearch — but it’s an essential component for Graylog to function.
An important warning before installing
MongoDB version 8.0 has kernel incompatibilities with Ubuntu 26.04. The stable, tested version for this implementation is 7.0. This is one of those cases where the latest version is not the best choice.
Installation
Add the official MongoDB 7.0 repository and install.
curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor
echo "deb [signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-7.0.list
apt update
apt install mongodb-org -yConfiguration
We apply the same pattern as with Wazuh: move data to the /data volume and logs to the /log volume, using bind mounts to keep the original paths intact.
# Create directory structuremkdir -p /data/mongodb/lib /log/mongodb
# Assign permissions to the service userchown mongodb:mongodb /data/mongodb/lib /log/mongodb
# Bind mountecho "/data/mongodb/lib /var/lib/mongodb none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/log/mongodb /var/log/mongodb none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
Why does this pattern repeat?
We apply the same storage strategy to every component in the stack. The reason is simple: in a production environment, data must survive an OS reinstallation. If the OS lives on / (60 GB) and data on /data (150 GB), I can reinstall Ubuntu without losing any application data.
The /log volume (20 GB) is independent because logs have a different lifecycle — they rotate, compress, and get deleted — and we don't want their growth to affect either the OS or application data.
Graylog: Centralized Log Management with TLS Integration to Wazuh Indexer
Part 4
Graylog is the heart of log management in this platform. It receives security events from Fluent Bit, indexes them in OpenSearch (through Wazuh Indexer), enables real-time searches, and exposes an API that we later connect to Claude via MCP.
Installation
Graylog requires Java as a dependency. We install Java 17 and then the Graylog 7.1 server.
apt install openjdk-17-jre-headless -y
wget https://packages.graylog2.org/repo/packages/graylog-7.1-repository_latest.deb
dpkg -i graylog-7.1-repository_latest.deb
apt-get update
apt install graylog-server -yConfiguration
# Create directory structuremkdir -p /data/graylog/lib/journal /data/graylog/jks /var/lib/graylog-server/journal /log/graylog
# Assign permissions to the service userchown -R graylog:graylog /data/graylog/lib /var/lib/graylog-server/journal /log/graylog
# Bind mountecho "/data/graylog/lib/journal /var/lib/graylog-server/journal none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/log/graylog /var/log/graylog-server none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
Credential configuration
Graylog requires two key values in its main configuration file.
# password_secret - internal encryption key (64 characters)cat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 64 | head -n 1
# root_password_sha2 - SHA256 hash of the administrator passwordecho -n '<password>' | shasum -a 256 | cut -d' ' -f1
# /etc/graylog/server/server.confpassword_secret = <password_secret>
root_password_sha2 = <root_password>
Integration with Wazuh Indexer
This is one of the most important steps and the one most frequently omitted in generic guides. Graylog needs to connect to Wazuh Indexer (OpenSearch) over HTTPS, which requires it to trust Wazuh’s CA certificate.
The solution is to incorporate Wazuh’s CA into a Java Key Store (JKS) that Graylog can use.
# Copy the base Java keystorecp /usr/share/graylog-server/jvm/lib/security/cacerts /data/graylog/jks/graylog.jks
cd /data/graylog/jks
# Import the Wazuh CA certificate
# When keytool asks "Trust this certificate? [no]:", answer: y
# Set a custom or random password
keytool -importcert -keystore graylog.jks -storepass <password> -alias wazuh-ca -file /etc/wazuh-indexer/certs/root-ca.pem
Then configure Graylog to use this keystore at startup.
# /etc/default/graylog-server - Enter the password defined in the previous stepGRAYLOG_SERVER_JAVA_OPTS="-Djavax.net.ssl.trustStore=/data/graylog/jks/graylog.jks -Djavax.net.ssl.trustStorePassword=<password>"
# Assign permissions to the service userchown -R graylog:graylog /data/graylog/jks
Why not just disable TLS verification?
It’s a common temptation to use ssl_verify=false to skip this entire process. The problem is that in production this eliminates a real security layer: any server could present itself as Wazuh Indexer and Graylog would accept it without question. The JKS procedure takes ten extra minutes and guarantees secure communication between components.
Fluent Bit: The Bridge Between Wazuh and Graylog
Part 5
Press enter or click to view image in full size
Fluent Bit is the component that connects Wazuh with Graylog. Its function is simple but critical: read the JSON alerts file that Wazuh generates in real time and forward each event to Graylog over TCP. It’s lightweight, efficient, and consumes minimal resources even under high event load.
Why Fluent Bit instead of Filebeat?
Wazuh installs Filebeat by default to export data to ElasticSearch. Filebeat can be configured to send to Graylog (at the same time, Graylog’s data source is Wazuh), but Fluent Bit is significantly lighter, has better support for complex pipelines, and consumes less memory.
The decision is clear: we disable Filebeat (covered in the Wazuh article) and install Fluent Bit.
Installation
Ubuntu 26.04 (Resolute Raccoon) doesn’t yet have official Fluent Bit packages. The solution is to use the Noble (Ubuntu 24.04) packages, which are compatible.
sh -c 'curl https://packages.fluentbit.io/fluentbit.key | gpg --dearmor > /usr/share/keyrings/fluentbit-keyring.gpg'
echo "deb [signed-by=/usr/share/keyrings/fluentbit-keyring.gpg] https://packages.fluentbit.io/ubuntu/noble noble main" | tee /etc/apt/sources.list.d/fluent-bit.list
apt update
apt install fluent-bit -yConfiguration
# Create directory structuremkdir -p /log/fluent-bit /var/log/fluent-bit
# Bind mountecho "/log/fluent-bit /var/log/fluent-bit none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
The main configuration file
This is the core of Fluent Bit. It defines three sections: the global service, the data input (INPUT), and the output (OUTPUT).
Pay attention when copying: The Fluent Bit configuration file is sensitive to indentation. Incorrect indentation will prevent the service from starting.
# /etc/fluent-bit/fluent-bit.conf[SERVICE]
flush 5
daemon Off
log_level info
log_file /log/fluent-bit/td-agent-bit.log
parsers_file parsers.conf
plugins_file plugins.conf
http_server Off
storage.metrics on
storage.path /tmp/storage
storage.sync normal
storage.checksum off
storage.backlog.mem_limit 5M
[INPUT]
name tail
path /var/ossec/logs/alerts/alerts.json
tag wazuh
parser json
Buffer_Max_Size 5MB
Buffer_Chunk_Size 400k
storage.type filesystem
Mem_Buf_Limit 512MB
[OUTPUT]
Name tcp
Host localhost
Port 5555
net.keepalive off
Match wazuh
Format json_lines
json_date_key true
How the pipeline works
- INPUT
tail— Reads the/var/ossec/logs/alerts/alerts.jsonfile continuously, similar totail -f. Every time Wazuh writes a new alert, Fluent Bit detects it. - Tag
wazuh— Labels each event so the OUTPUT knows what to process. - OUTPUT
tcp— Sends each event to port 5555 on localhost in JSON format, where Graylog will listen with a Raw HTTP input.
The on-disk buffer storage.type filesystem ensures no events are lost if Graylog is momentarily unreachable. Events accumulate and are resent once the connection is re-established.
Optional: REST API — Monitor Fluent Bit data pipelines.
https://docs.fluentbit.io/manual/administration/monitoring
Grafana: Real-Time Operational Dashboards
Part 6
Grafana is the visualization layer of the platform. It connects to Wazuh as a datasource and allows building operational dashboards that show in real time the state of the infrastructure: Wazuh alerts, log volume, access patterns, and any metric Graylog or Wazuh can provide.
In terms of base installation and configuration, Grafana is the simplest component in the stack. The complexity comes later when we integrate SSO authentication.
Installation
wget -O /etc/apt/keyrings/grafana.asc https://apt.grafana.com/gpg-full.key
echo "deb [signed-by=/etc/apt/keyrings/grafana.asc] https://apt.grafana.com stable main" | tee -a /etc/apt/sources.list.d/grafana.list
apt update
apt install grafana -yConfiguration
# Create directory structuremkdir -p /data/grafana/lib /log/grafana
# Assign permissions to the service userchown grafana:grafana /data/grafana/lib /log/grafana
# Bind mountecho "/data/grafana/lib /var/lib/grafana none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/log/grafana /var/log/grafana none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
The specific security dashboards — visualizing Wazuh alerts, Graylog logs, and service metrics — depend on the datasources we’ll configure once the entire platform is operational.
A note on the visualization architecture:
In many similar implementations, Wazuh already includes its own dashboard based on OpenSearch Dashboards (Kibana). So why add Grafana?
The reason is datasource flexibility. The Wazuh Dashboard can only visualize data from Wazuh Indexer. Grafana can simultaneously connect to Wazuh, Prometheus, InfluxDB, SQL databases, and dozens of other sources. A single dashboard can show Wazuh alerts alongside infrastructure metrics, application logs, and any other data source — all in real time, with its own alerting system.
OAuth2-Proxy: Single Sign-On with IdP
Part 7
OAuth2-Proxy is the component that eliminates the need to manage users and passwords in Wazuh, Graylog, and Grafana separately. Instead, it delegates all authentication to IdP. The user logs in once with their corporate account and accesses all three systems without entering credentials again.
This article covers the installation of OAuth2-Proxy and its base configuration. The integration with each application is completed in the Authentication article.
How the flow works:
When a user accesses https://site.domain.com
- Nginx receives the request and asks OAuth2-Proxy if the user is authenticated
auth_request /oauth2/auth - If there’s no active session, OAuth2-Proxy redirects the user to IdP portal.
- The user authenticates with their corporate account.
- IdP returns an ID Token to OAuth2-Proxy.
- OAuth2-Proxy validates the token and creates a session stored in Redis.
- Nginx propagates the user’s identity in an HTTP header.
- Each application receives the header and assigns permissions accordingly.
OAuth2 Proxy uses several layers to confirm a token’s validity
Signature Verification: The proxy checks that the token’s cryptographic signature was created by the trusted Identity Provider. It typically retrieves public keys automatically from the IdP JWKS (JSON Web Key Set) endpoint, which is discovered via the OpenID Connect well-known configuration URL.
Claim Validation: Once the signature is verified, it inspects specific fields (claims) within the token:
iss(Issuer): Must match the configured provider URL.
aud(Audience): Must contain theclient_idof the OAuth2 Proxy instance to ensure the token was intended for this specific application.
exp(Expiration): Ensures the token has not expired.
The result: a single sign-on for the entire platform, with persistent sessions stored in Redis.
Installation
wget https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.15.2/oauth2-proxy-v7.15.2.linux-amd64.tar.gz
tar -xzvf oauth2-proxy-v7.15.2.linux-amd64.tar.gz
chown root:root oauth2-proxy-v7.15.2.linux-amd64/oauth2-proxy
mv oauth2-proxy-v7.15.2.linux-amd64/oauth2-proxy /usr/sbin/Configuration
# Create directory structuremkdir -p /etc/oauth2-proxy /log/oauth2-proxy
# Create service useruseradd -d /dev/null oauth2-proxy -s /usr/sbin/nologin
# Create filestouch /etc/systemd/system/oauth2-proxy.service
touch /etc/oauth2-proxy/service.cfg
# Assign permissions to the service userchown oauth2-proxy:oauth2-proxy /log/oauth2-proxy
# Generate the cookie secret (32 random characters)cat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 32 | head -n 1
Configuration file:
Case with IdP Microsoft Entra ID.
Enable debugs on errors.
# /etc/oauth2-proxy/service.cfgclient_id = "<app_id>"
client_secret = "<app_secret>"
oidc_issuer_url = "https://login.microsoftonline.com/<tenant_id>/v2.0"
cookie_secret = "<cookie_secret>"
cookie_domains = "<domain.com>"
email_domains = "<domain.com>"
whitelist_domains = "*.<domain.com>"
cookie_expire = "24h"
cookie_httponly = true
cookie_refresh = "50m"
cookie_secure = true
logging_filename = "/log/oauth2-proxy/oauth2.log"
logging_max_size = 100
logging_max_age = 5
provider = "oidc"
provider_display_name = "OIDC"
redis_connection_url = "redis://127.0.0.1:6379"
scope = "openid offline_access"
session_store_type = "redis"
set_xauthrequest = true
silence_ping_logging = true
skip_provider_button = true
upstreams = [ "file:///dev/null" ]
#show_debug_on_error = true
More details:
https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview
Registering in Microsoft Entra ID
⚠ Important: The Authorization Code Flow is the primary method to authenticate users, an industry-standard. Both tokens — Access and ID — are not enabled for implicit or hybrid flows.
The variety of flows often leads to confusion. With Auth Code Flow and
openidscope, we will always obtain an Identification Token👍
For OAuth2-Proxy to work, you need to register an application in the Azure portal:
- Azure Portal → Entra ID → App registrations → New registration
- Application name, account type, and redirect URI:
https://system.<domain.com>/oauth2/callback - Obtain the Application Client ID and Tenant ID.
- Create a Client Secret under “Certificates & Secrets”
- In “API permissions”, add
openidandoffline_access
These values are used in client_id, client_secret, and oidc_issuer_url in the configuration file.
Create service
# /etc/systemd/system/oauth2-proxy.service[Unit]
Description=OAuth2 Proxy Daemon
After=network.target
[Service]
User=oauth2-proxy
Group=oauth2-proxy
Type=simple
ExecStart=/usr/sbin/oauth2-proxy --config=/etc/oauth2-proxy/service.cfg
Restart=always
RestartSec=10
ProtectSystem=full
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
Redis: Session Persistence and Storage
Part 8
Redis solves a real problem: OAuth2-Proxy session cookies can grow large (cookie bloat) and exceed size limits. Storing sessions in Redis instead of in the cookie keeps the size controlled and allows sessions to survive proxy restarts.
Installation
apt install redis-server -yConfiguration
# Create directory structuremkdir -p /data/redis/lib /log/redis
# Assign permissions to the service userchown redis:redis /data/redis/lib /log/redis
# Bind mountecho "/data/redis/lib /var/lib/redis none defaults,bind,nofail 0 0" >> /etc/fstab
echo "/log/redis /var/log/redis none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
Nginx: Reverse Proxy with TLS and Header-Based Authentication
Part 9
Nginx is the entry point to the entire platform. It acts as a reverse proxy with TLS, routes traffic to Wazuh, Graylog, MCP, and Grafana, and coordinates with OAuth2-Proxy (except with MCP) to validate user identity on every request.
Installation
apt install nginx -yConfiguration
# Create directory structuremkdir -p /log/nginx /etc/nginx/TLS
# Create filestouch /etc/nginx/sites-available/{wazuh,graylog,graylog-mcp,grafana}
touch /etc/nginx/snippets/{security-headers.conf,oauth2-proxy.conf}
touch /etc/nginx/TLS/{certificate.crt,private.key}
# Delete default hostrm /etc/nginx/sites-enabled/default
# Bind mountecho "/log/nginx /var/log/nginx none defaults,bind,nofail 0 0" >> /etc/fstab
systemctl daemon-reload
mount -a
Main configuration: nginx.conf
The most notable aspect of this configuration is the map block: it extracts the username from the email returned by OIDC, taking everything before the @. This allows receive the username instead of the full email address.
To avoid excessive logging, the access log is set to off.
# /etc/nginx/nginx.confuser www-data;
worker_processes auto;
worker_cpu_affinity auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 1024;
}
http {
map $upstream_http_x_auth_request_email $http_x_auth_user {
"~^(?<user>[^@]+)@" $user;
}
include mime.types;
default_type application/octet-stream;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
types_hash_max_size 2048;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:!aNULL:!MD5:!3DES:!CBC:!SHA1';
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 15m;
access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
error_log /var/log/nginx/error.log;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}
Security Headers snippet
Best practices that strengthen your website’s security against common attacks.
# /etc/nginx/snippets/security-headers.confadd_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
add_header X-Frame-Options "SAMEORIGIN";
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff;
add_header X-Download-Options "noopen";
add_header Permissions-Policy 'geolocation=(), microphone=(), camera=()';
add_header Referrer-Policy 'no-referrer';
add_header Content-Security-Policy 'upgrade-insecure-requests';
OAuth2-Proxy snippet
This snippet is included in every virtual host that requires authentication. It defines two locations: one for the OAuth2 flow and one internal for validating sessions.
# /etc/nginx/snippets/oauth2-proxy.conflocation /oauth2/ {
proxy_pass http://localhost:4180;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
access_log off;
}
location = /oauth2/auth {
internal;
proxy_pass http://localhost:4180;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
The
Content-Length: ""headers andproxy_pass_request_body offare critical. Without them, authentication requests to internal APIs fail. This configuration has been validated in production.
Virtual hosts: one per application
Each application has its own configuration file. The pattern is consistent:
- Redirect HTTP to HTTPS (301).
- TLS with Wildcard certificate.
- Include security headers and OAuth2-Proxy snippet.
- Proxy pass to each application’s local port.
Wazuh Host
⚠ Firstly, proxy authentication in Wazuh does not require creating internal users.
⚠ This configuration is intended for administration; therefore, it has the
"admin"backend role hardcoded.
proxy_set_header x-proxy-roles "admin"You can associate Security Groups or Nginx mappings with custom backend roles. See the Custom Backend Roles references later in the authentication section.
# /etc/nginx/sites-available/wazuhserver {
listen 80;
server_name wazuh.<domain.com>;
access_log off;
log_not_found off;
return 301 https://wazuh.<domain.com>$request_uri;
}
server {
listen 443 ssl;
server_name wazuh.<domain.com>;
ssl_certificate /etc/nginx/TLS/certificate.crt;
ssl_certificate_key /etc/nginx/TLS/private.key;
include /etc/nginx/snippets/security-headers.conf;
include /etc/nginx/snippets/oauth2-proxy.conf;
location / {
#auth_request /oauth2/auth;
error_page 401 = /oauth2/start;
auth_request_set $user $http_x_auth_user;
proxy_set_header x-proxy-user $user;
proxy_set_header x-proxy-roles "admin";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 10s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass https://localhost:8080;
access_log off;
log_not_found off;
}
}
Graylog Host
# /etc/nginx/sites-available/graylogserver {
listen 80;
server_name graylog.<domain.com>;
access_log off;
log_not_found off;
return 301 https://graylog.<domain.com>$request_uri;
}
server {
listen 443 ssl;
server_name graylog.<domain.com>;
ssl_certificate /etc/nginx/TLS/certificate.crt;
ssl_certificate_key /etc/nginx/TLS/private.key;
include /etc/nginx/snippets/security-headers.conf;
include /etc/nginx/snippets/oauth2-proxy.conf;
location / {
#auth_request /oauth2/auth;
error_page 401 = /oauth2/start;
auth_request_set $user $http_x_auth_user;
proxy_set_header x-proxy-user $user;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 10s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:9000;
proxy_set_header X-Graylog-Server-URL https://$server_name;
access_log off;
log_not_found off;
}
}
Graylog MCP Host
# /etc/nginx/sites-available/graylog-mcpserver {
listen 443 ssl;
server_name graylog-mcp.<domain.com>;
ssl_certificate /etc/nginx/TLS/certificate.crt;
ssl_certificate_key /etc/nginx/TLS/private.key;
include /etc/nginx/snippets/security-headers.conf;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 10s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:9000/api/;
access_log off;
log_not_found off;
}
}
Grafana Host
# /etc/nginx/sites-available/grafanaserver {
listen 80;
server_name grafana.<domain.com>;
access_log off;
log_not_found off;
return 301 https://grafana.<domain.com>$request_uri;
}
server {
listen 443 ssl;
server_name grafana.<domain.com>;
ssl_certificate /etc/nginx/TLS/certificate.crt;
ssl_certificate_key /etc/nginx/TLS/private.key;
include /etc/nginx/snippets/security-headers.conf;
include /etc/nginx/snippets/oauth2-proxy.conf;
location / {
#auth_request /oauth2/auth;
error_page 401 = /oauth2/start;
auth_request_set $user $http_x_auth_user;
proxy_set_header x-proxy-user $user;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 10s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://localhost:3000;
access_log off;
log_not_found off;
}
}
The auth_request is intentionally commented during initial configuration — it's enabled in the Authentication article, once all roles and users are configured in each application.
graylog-mcp: a special virtual host without OAuth2 so Claude can access the Graylog MCP Bridge directly with Basic authentication. Covered in the Graylog MCP article.
# Create linkscd /etc/nginx/sites-enabled
ln -s ../sites-available/wazuh
ln -s ../sites-available/graylog
ln -s ../sites-available/graylog-mcp
ln -s ../sites-available/grafana
Configure certificates
/etc/nginx/TLS/certificate.crt — Site and intermediate certificates.
/etc/nginx/TLS/private.key — Private key.
Get Antonio VS’s stories in your inbox
Join Medium for free to get updates from this writer.
TLS and verification
# Certificate with restrictive permissionschmod 400 /etc/nginx/TLS/*
# Verify configuration before startingnginx -t
Expected response:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful# Restartsystemctl restart nginx
Troubleshooting Guide
Log Paths and Common Issues in a Complex Security Stack
Part 10
When something fails in a stack of this complexity — and something always does — knowing exactly where to look for information is the difference between resolving the problem in five minutes or an hour. This article documents the log paths for all components and the most common problems encountered during implementation.
Real-time log monitoring
To monitor any service in real time.
# Filtered error warn
journalctl -u <service> | grep -i -E "error|warn"# Limit the number of the last events shown
journalctl -u <service> -n <number>
# Show only the most recent journal entries, and continuously print new entries
journalctl -fu <service>
# Jump to the end and augment log lines with explanation texts from the message catalog
journalctl -xeu <service>
Where <service> can be any of the following:
wazuh-dashboardwazuh-indexerwazuh-managergraylog-servermongodgrafana-serveroauth2-proxyfluent-bitnginxredis-server
Log file paths
For direct access to log files:
tail -f <path>- Wazuh-Indexer
/log/wazuh-indexer/wazuh-cluster.log - Graylog
/log/graylog/server.log - MongoDB
/log/mongodb/mongod.log - Grafana
/log/grafana/grafana.log - Fluent Bit
/log/fluent-bit/td-agent-bit.log - OAuth2-Proxy
/log/oauth2-proxy/oauth2.log - Nginx
/log/nginx/error.log - Redis
/log/redis/redis-server.log
Known issue: unexpected server restart
Symptom: Wazuh Manager fails to start after an unexpected server restart. The log shows:
ERROR: Another instance is locking this process. If you are sure that
no other instance is running, please remove
/var/ossec/var/start-script-lock/Cause: Wazuh creates a lock directory when starting and removes it on clean shutdown. If the server restarts abruptly (power cut, OOM killer, etc.), the directory remains and the next startup fails.
Solution: Remove the lock directory manually and start service.
rm -rf /var/ossec/var/start-script-lock/
systemctl start wazuh-managerRecommended diagnostic approach
When something isn’t working, the recommended review order is:
- Is the service running?
systemctl status <service> - Any recent errors?
journalctl -xeu <service> --since "10 minutes ago" - Does the service’s own log file have more detail?
tail -50 <log_path> - For network issues between components: verify ports are listening
ss -tlnp | grep <port> - For authentication issues: check the OAuth2-Proxy log and the headers Nginx is sending.
The most frequent problems in this implementation were:
- Incorrect indentation in
fluent-bit.conf(service starts without errors but doesn't process data). - Wazuh TLS certificate not included in Graylog’s JKS (connection silently rejected).
- Service startup order: MongoDB must be running before Graylog.
Authentication: SSO and Integration Graylog with Wazuh
Part 11
Press enter or click to view image in full size
This is the most complex article in the series. Up to this point, all components are installed, but each has its own authentication system. The goal of this article is to configure the three solutions to accept the user identity propagated by OAuth2-Proxy via Nginx — completely eliminating local passwords from the daily login cycle.
⚠ Important: Follow the order A->B->C->D. Configuring proxy authentication on a system before the user has been created will result in loss of access.
Verify the response of each curl command before proceeding.
# Create random passwords for userscat /dev/urandom | tr -dc "a-zA-Z0-9" | fold -w 16 | head -n 1
How Proxy Authentication Works
The mechanism is as follows: Nginx validates the user’s identity with OAuth2-Proxy and then injects the username as an HTTP header x-proxy-user in each request to applications. Each application must be configured to trust this header and automatically assign roles.
Continue with the following four steps:
11-A. Wazuh OAuth2
# Reload systemctl and start servicessystemctl daemon-reload
systemctl start oauth2-proxy wazuh-manager wazuh-indexer wazuh-dashboard
Startups may take some time. Check the logs for errors before continuing.
Creating Role Mapping
Wazuh needs a role mapping that associates the username propagated by the proxy with the administrator role.
Option A — Webhttps://wazuh.<domain.com>/app/security#/security?tab=roleMapping
Use the “admin” credential from the “wazuh-passwords.txt” file.
- Role mapping name:
ProxyAuth - Roles:
administrator - Custom rules → “Add new rule”
- User field:
user_name - Search operation:
MATCH - Value:
<AD_user>
Option B — API
Use the “wazuh-wui” credential from the “wazuh-passwords.txt” file.
# Generate tokentoken=$(curl -u wazuh-wui:<password> -k -X POST "https://localhost:55000/security/user/authenticate?raw=true")
# Create rulecurl -k -X POST "https://localhost:55000/security/rules" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d '{
"name": "ProxyAuth",
"rule": {
"MATCH": {
"user_name": "<AD_user>"
}
}
}'
Rule created with ID: 100
# Assign the rule to the administrator rolecurl -k -X POST "https://localhost:55000/security/roles/1/rules?rule_ids=<id_rule>" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json"
— — — end options
Configure proxy authentication in OpenSearch
# /etc/wazuh-dashboard/opensearch_dashboards.yml - Add and replace (part of the file)opensearch_security.auth.type: "proxy"
opensearch_security.proxycache.user_header: "x-proxy-user"
opensearch_security.proxycache.roles_header: "x-proxy-roles"
opensearch_security.proxycache.proxy_header: "x-forwarded-for"
opensearch_security.proxycache.proxy_header_ip: "127.0.0.1"
opensearch.requestHeadersAllowlist: ["securitytenant","Authorization","x-proxy-user","x-proxy-roles","x-forwarded-for"]
# /etc/wazuh-indexer/opensearch-security/config.yml — Enable xff and proxy auth (part of the file)xff:
enabled: true
internalProxies: '127\.0\.0\.1'
proxy_auth_domain:
description: "Authenticate via proxy"
http_enabled: true
# /etc/nginx/sites-enabled/wazuh - Enableauth_request /oauth2/auth;
# Apply changescd /etc/wazuh-indexer/opensearch-security/
/usr/share/wazuh-indexer/plugins/opensearch-security/tools/securityadmin.sh \
-cacert /etc/wazuh-indexer/certs/root-ca.pem \
-cert /etc/wazuh-indexer/certs/admin.pem \
-key /etc/wazuh-indexer/certs/admin-key.pem \
-h 127.0.0.1
systemctl restart wazuh-dashboard nginx
As of now, Wazuh uses OAuth2-Proxy for authentication.
Custom Backend Roles
If your goal is to replace the backend role hardcoded with a Group 365:
- In the App Entra ID, add Security Group ID in Token optional Group Claims (Token configuration).
- Next, obtain the Object ID of group 365.
- Duplicate the “all_access” role at:
https://wazuh.<domain.com>/app/security-dashboards-plugin#/roles/duplicate/all_accessand name it"Group365"for reference. - Then, go to the mapping page:
https://wazuh.<domain.com>/app/security-dashboards-plugin#/roles/edit/Group365/mapuserand paste the Object ID into the Backend roles field. - In
/etc/wazuh-indexer/opensearch.yml, add"Group365"to the list defined underplugins.security.restapi.roles_enabled. - In Nginx Wazuh Host set
auth_request_set $roles $upstream_http_x_auth_request_groupsandproxy_set_header x-proxy-roles $roles. - Restart wazuh-indexer and nginx
systemctl restart wazuh-indexer nginx
References
# /etc/nginx/sites-enabled/wazuhlocation / {
auth_request /oauth2/auth;
error_page 401 = /oauth2/start;
auth_request_set $user $http_x_auth_user;
proxy_set_header x-proxy-user $user;
auth_request_set $roles $upstream_http_x_auth_request_groups;
proxy_set_header x-proxy-roles $roles;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_http_version 1.1;
proxy_read_timeout 10s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass https://localhost:8080;
access_log off;
log_not_found off;
}# /etc/wazuh-indexer/opensearch.ymlplugins.security.restapi.roles_enabled:
- "all_access"
- "security_rest_api_access"
- "Group365"Alernative Map Nginx
# /etc/nginx/nginx.confhttp {
map $upstream_http_x_auth_request_email $http_x_auth_user {
"~^(?<user>[^@]+)@" $user;
}map $http_x_auth_user $roles {
"<AD_user1>" "<group_object_id>";
"<AD_user2>" "readall";
}
11-B. Connect Graylog to Wazuh Indexer
Create user in Wazuh for Graylog
Graylog needs a user in Wazuh Indexer to be able to index logs.
Use the “admin” credential from the “wazuh-passwords.txt” file.
User defined “graylog” (or you can choose another name).
Option A — Webhttps://wazuh.<domain.com>/app/security-dashboards-plugin#/users
- Username:
graylog - Password:
<password> - Backend roles:
admin
Option B — API
# Create usercurl -k -X PUT "https://127.0.0.1:9200/_plugins/_security/api/internalusers/graylog" \
-H "Content-type: application/json" \
-u "admin:<password>" \
-d '{
"password": "<password>",
"backend_roles": ["admin"]
}'
— — — end options
# /etc/graylog/server/server.conf - Enable ElasticSearch connectionelasticsearch_hosts = https://graylog:<password>@127.0.0.1:9200
Use “127.0.0.1” and not “localhost” — Wazuh’s TLS certificate requires the SAN (Subject Alternative Name) to match exactly.
11-C. Graylog OAuth2
# Start servicessystemctl start mongod graylog-server
Use the “admin” credential, reference.
Option A — Web
Enable headerhttps://graylog.<domain.com>/system/authentication/authenticator/edit
- Enabled: ✓
- Username header:
x-proxy-user
Create userhttps://graylog.<domain.com>/system/users/new
- First Name:
<first_name> - Last Name:
<last_name> - Username:
<AD_user> - E-Mail Address:
<email> - Assign Roles:
Admin - Password:
<password>
Option B — API
# Enable headercurl -X PUT "http://localhost:9000/api/system/authentication/http-header-auth-config" \
-H "Content-Type: application/json" \
-H "X-Requested-By: cli" \
-u "admin:<password>" \
-d '{
"enabled": true,
"username_header": "x-proxy-user"
}'
# Create usercurl -X POST "http://localhost:9000/api/users" \
-H "Content-Type: application/json" \
-H "X-Requested-By: cli" \
-u "admin:<password>" \
-d '{
"first_name": "<first_name>",
"last_name": "<last_name>",
"username": "<AD_user>",
"email": "<email>",
"password": "<password>",
"roles": ["Admin"],
"permissions": []
}'
— — — end options
# /etc/graylog/server/server.conf -Enable and edittrusted_proxies = 127.0.0.1/32
# /etc/nginx/sites-enabled/graylog - Enableauth_request /oauth2/auth;
# Apply changessystemctl restart graylog-server nginx
11-D. Grafana OAuth2
# Start servicesystemctl start grafana-server
Create user
Use the default “admin:admin” credential.
Option A — Webhttps://grafana.<domain.com>/admin/users/create
- Name:
<name> - Email:
<email> - Username:
<AD_user> - Password:
<password>
Next screen:
- Enable
"Grafana Admin”and change to"Admin"role.
Option B — API
# Create usercurl -X POST "http://localhost:3000/api/admin/users" \
-H "Content-Type: application/json" \
-u "admin:<password>" \
-d ' {
"name":"<name>",
"email":"<email>",
"login":"<AD_user>",
"password":"<password>"
}'
Account created with ID: 2
# Assign global admincurl -X PUT "http://localhost:3000/api/admin/users/<user_id>/permissions" \
-H "Content-Type: application/json" \
-u "admin:<password>" \
-d '{
"isGrafanaAdmin": true
}'
# Assign organization administrator rolecurl -X PATCH "http://localhost:3000/api/orgs/1/users/<user_id>" \
-H "Content-Type: application/json" \
-u "admin:<password>" \
-d '{
"role":"Admin"
}'
— — — end options
# /etc/grafana/grafana.ini - Enable and edit[auth.proxy]
enabled = true
header_name = x-proxy-user
header_property = username
auto_sign_up = false
sync_ttl = 3600
whitelist = 127.0.0.1
# /etc/nginx/sites-enabled/grafana - Enableauth_request /oauth2/auth;
# Apply changessystemctl restart grafana-server nginx
⚠ Important: For security reasons, replace the default password for the user “admin”.
Graylog Ingest: Configuring Data Ingest from Fluent Bit
Part 12
With all components installed and SSO authentication configured, it’s time to connect the final wires: configure Graylog to receive the events that Fluent Bit sends from Wazuh, and activate all services so that the platform is fully operational.
Fluent Bit sends Wazuh events to “localhost:5555” in JSON format. Graylog needs an active input listening on that port to process them.
Option A — Web interface:https://graylog.<domain.com>/system/inputs
- Select input type:
Raw HTTP - Click “Launch new input”
- Configure:
Title:Wazuh
Bind address:127.0.0.1 - Click “Launch Input”
- Follow the wizard to start the input and verify your diagnosis.
Option B — API:
# Create RAW HTTP input - Graylog admin credentialcurl -X POST "http://localhost:9000/api/system/inputs" \
-H "Content-Type: application/json" \
-H "X-Requested-By: cli" \
-u "admin:<password>" \
-d '{
"title": "Wazuh",
"type": "org.graylog2.inputs.raw.http.RawHttpInput",
"global": true,
"configuration": {
"bind_address": "127.0.0.1",
"port": 5555,
"recv_buffer_size": 1048576,
"max_chunk_size": 65536
}
}'
bind_address: 127.0.0.1 limits listening to the local interface — Fluent Bit runs on the same server, so there’s no need to expose the port externally.
Standard configuration uses the default stream.
— — — end options
With the platform fully configured, we enable automatic service startup and start Fluent Bit to begin receiving events:
systemctl enable fluent-bit mongod graylog-server grafana-server oauth2-proxy
systemctl start fluent-bitOrder matters: MongoDB must be running before Graylog, and OAuth2-Proxy before Nginx. The “enable” command ensures that systemd respects dependencies on future server restarts.
Verification: Is everything Working?
Once all services are active, verify the complete flow:
- Fluent Bit is reading Wazuh alerts:
tail -f /log/fluent-bit/td-agent-bit.log
You should see error-free processing lines. - Graylog is receiving messages: In the web interface, navigate to Search and verify that messages with the source “wazuh” are arriving.
- SSO Authentication: Access https://site.domain.com from your browser — it should redirect you to the IdP and then return authenticated.
- Grafana connected to Wazuh: In Grafana, configure a data source pointing to Wazuh and verify that it returns data.
Graylog MCP + Claude: Querying Security Logs in Natural Language with AI
Part 13
Press enter or click to view image in full size
This is the most groundbreaking article in the series. The platform is already operational: Wazuh detects threats, Fluent Bit transports events, Graylog indexes them, and Grafana visualizes them. But there’s an additional layer that completely changes how we interact with security data: integrating Claude as an AI client on the Graylog REST API — MCP is more than an API; it’s a Bridge.
What is MCP and why does it matter?
MCP (Model Context Protocol) is a protocol that allows language models like Claude to connect directly with external tools and data sources. In this case, Graylog exposes its API as an MCP server, and Claude acts as an intelligent client that can query, filter, and analyze security logs.
The change this produces is significant:
- Instead of building queries in the Graylog interface, the analyst writes in natural language: “Were there any failed login attempts in the last 2 hours?”
- Instead of reviewing hundreds of log lines, Claude summarizes the relevant patterns and presents them in context.
- Non-technical users — operators without SIEM experience — can interact directly with the security data.
- Every query is logged in Graylog like any other API interaction, maintaining complete traceability.
Architecture of integration
A specific virtual host was created in Nginx “graylog-mcp” without OAuth2-Proxy — Claude authenticates directly with Basic credentials encoded in base64.
Installation
On the client:
- NodeJS:
https://nodejs.org/en/download - Claude Desktop:
https://claude.com/download
Configuration in Graylog
Step 1: Enable MCP in Graylog
https://graylog.<domain.com>/system/configurations/MCP
Step 2: Create a user token
Navigate to https://graylog.<domain.com>/system/users → Actions → More → Edit tokens.
- Token Name:
<name> - Token TTL:
<time>
TTL Syntax Examples: for 60 seconds: PT60S, for 60 minutes: PT60M, for 24 hours: PT24H, for 30 days: P30D
Step 3: Base64 Encoding of Credentials
The format is "<token>:token” encoded in base64.
echo -n "<token>:token" | base64Claude Desktop Configuration
Edit the “claude_desktop_config.json” file in Claude Desktop, usually located in "%APPDATA%\CLAUDE\".
{
"mcpServers": {
"Graylog": {
"command": "C:\\Program Files\\nodejs\\npx",
"args": [
"mcp-remote",
"https://graylog-mcp.<domain.com>/mcp",
"--header",
"Authorization: Basic <credential_b64>"
]
}
}
}Replace
<domain.com>with your actual domain and<credential_b64>with the base64 value generated in the previous step.
Restart Claude Desktop and check the connection status in:
Settings → Developer
The state should be running or refer to the logs.
Press enter or click to view image in full size
Real-world use cases
Once connected, Claude can answer questions such as:
- “How many critical alerts did Wazuh generate today?”
- “Are there any IPs that repeatedly attempted to connect without success?”
- “Summarize the events of the last 30 minutes”
- “Which services experienced errors in the last 6 hours?”
The response is not a list of raw logs — it’s a natural language analysis with relevant patterns identified and contextualized.
Security considerations
- Access to graylog-mcp is restricted to HTTPS with a valid certificate.
- Base64-encoded credentials are NOT encrypted — they are only encoded. True security lies in TLS and ensuring the endpoint is only accessible from the corporate network.
- Every request from Claude is logged in the Graylog access log, maintaining a complete audit trail.
- Rotating the token periodically is a best practice.
Final Architecture, evidences, and Conclusions: An Open Source Security Platform in Production
Part 14
This is the final article in the series. After thirteen articles covering each component of the stack — from Wazuh to integration with Claude via MCP — it’s time to see the big picture and reflect on what worked, and what cost more than expected.
Wazuh
Press enter or click to view image in full size
Press enter or click to view image in full size
Graylog
Press enter or click to view image in full size
Grafana
Press enter or click to view image in full size
Claude
Press enter or click to view image in full size
Press enter or click to view image in full size
Press enter or click to view image in full size
The Complete Architecture
Press enter or click to view image in full size
The diagram shows two main flows that coexist on the platform:
Data Flow
Wazuh detects events and writes them in JSON format. Fluent Bit continuously reads this file and forwards each event to Graylog via TCP. Graylog indexes the data to Wazuh Indexer (returns the connection to OpenSearch). Grafana connects to Wazuh as a data source for dashboards. Claude accesses the Graylog MCP Bridge for natural language queries.
Authentication Flow
The user accesses any of the three systems with their corporate account. Nginx queries OAuth2-Proxy on each request. OAuth2-Proxy validates the session (stored in Redis) or redirects to IdP for authentication. Once authenticated, Nginx propagates the username as an HTTP header to the corresponding application.
Lessons Learned
What worked well:
- Separate LVM volumes — the best implementation decision. On three occasions, logs grew larger than expected; none of these affected the operating system or application data.
- Single authentication point — after the initial (and complex) setup, the user experience is seamless. One login for three systems.
- Fluent Bit vs. Filebeat — Fluent Bit’s resource consumption is significantly lower. On a shared server, this matters.
- Graylog MCP + Claude — the most differentiating component. It completely changed how non-technical users interact with security data.
What cost more than expected:
- TLS between Graylog and Wazuh Indexer — the JKS and CA certificate import is the step with the most incorrect documentation online. The guide in this article reflects what actually works.
- Order of operations in Authentication — configuring the authentication proxy before creating users results in loss of access. The order matters.
Is it worth it?
For an organization that wants true visibility into its infrastructure without paying for commercial SIEM tool licenses (which can cost tens of thousands of dollars annually), the answer is yes.
The real cost is implementation time and technical expertise. This well-documented stack can be replicated in a single workday using this guide. The necessary technical knowledge includes Linux administration, basic TLS concepts, and reading the official documentation.
What you get in return is a security platform with features comparable to commercial solutions, complete control over your data, no vendor lock-in, and the ability to extend it with any component you need.
About this series
This implementation was carried out in production for the IT Infrastructure. All documentation reflects real decisions, real problems, and solutions that work in production.
If you have questions, found a bug, or want to share an improvement, the comments are open.
Your Turn — Operational Ownership
From here, the real value comes from how you adapt it to your environment. As the operator or administrator, the next layer is yours to build:
Graylog: Create dedicated indexes and streams per data source; build a JSON extractor for the message field to enable structured search.
Vulnerability visibility: A custom script can reindex Wazuh’s vulnerability summary index and inject it into Graylog via a new GELF HTTP input — all through the APIs — making vulnerability summaries available in Grafana dashboards .
Alerting and reporting: Define alert conditions and scheduled reports based on what matters to your organization.
Grafana dashboards: Design views tailored to what your team or clients actually need to see.
API automation: Build scripts to automate recurring calls across the stack.
Threat intelligence: Cross-reference events with NIST NVD and CISA KEV for deeper context and custom correlations.
If you have questions or need guidance on the operational side, feel free to reach out — happy to support within my availability.
Author: Antonio Valenzuela Serra — SysAdmin — Chile — May 2026\