Skip to content

Wazuh + Suricata — SIEM and Network IDS for a Homelab

Suricata and Wazuh are complementary, not competing:

WazuhSuricata
SeesHost events — logins, file changes, config drift, running processes, agent reportsNetwork packets on a monitored interface
DetectsPolicy violations, FIM hits, rootkits, agent-based attacksKnown malicious patterns in traffic (Emerging Threats ruleset)
OutputsStructured alerts in OpenSearch, dashboard, APIeve.json — a firehose of events and alerts
Blind spotsAnything that happens on the wireAnything encrypted, or off the monitored interface

Run both and Wazuh becomes the single pane of glass for everything: its manager ingests Suricata’s eve.json via a local file decoder and presents the alerts alongside host events, correlated by time and source IP.

┌─────────────┐ ┌────────────────┐ ┌──────────────┐
│ Suricata │───────▶│ eve.json │───────▶│ Wazuh Manager│
│ (host NIC) │ │ (shared file) │ │ ossec.conf │
└─────────────┘ └────────────────┘ │ localfile │
└──────┬───────┘
┌─────────────┐ │
│ Wazuh Agents│─────────────────────────────────────────┤ (filebeat)
│ on clients │ TLS 1514 ▼
└─────────────┘ ┌──────────────┐
│Wazuh Indexer │
│ (OpenSearch) │
└──────┬───────┘
┌──────▼───────┐
│Wazuh Dashboard│
│(OSD + plugins)│
└──────────────┘

Three Wazuh services:

  • wazuh-indexer — OpenSearch fork. Stores every alert and event. The heavy one (1–2 GB JVM heap).
  • wazuh-manager — The brain. Runs decoders and rules on incoming events, talks to agents on port 1514, ships output to the indexer via filebeat.
  • wazuh-dashboard — OpenSearch Dashboards fork with Wazuh’s plugins baked in. Port 5601 (HTTPS).

Plus Suricata running on the host network so it can see real interface traffic.

Every Wazuh-to-Wazuh connection is mTLS. There’s no way to skip this — the containers refuse to come up without certs. Generate them with Wazuh’s own tool before bringing the stack up:

Terminal window
# Pull the cert generator and the config template
curl -sO https://packages.wazuh.com/4.14/wazuh-certs-tool.sh
curl -sO https://packages.wazuh.com/4.14/config.yml
# Edit config.yml — set node names and IPs:
# - wazuh-indexer → 192.168.1.248 (or the container name if single-host)
# - wazuh-manager → same
# - wazuh-dashboard → same
bash wazuh-certs-tool.sh -A
# Put them where the stack expects them
sudo mkdir -p /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs
sudo mv wazuh-certificates/* /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/

You end up with: root-ca.pem, root-ca-manager.pem, wazuh-indexer.pem + key, wazuh-manager.pem + key, wazuh-dashboard.pem + key.

/opt/stacks/security/compose.yaml
services:
wazuh-indexer:
image: wazuh/wazuh-indexer:4.14.3
container_name: wazuh-indexer
restart: unless-stopped
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 65536, hard: 65536 }
environment:
- "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g"
volumes:
- wazuh_indexer_data:/var/lib/wazuh-indexer
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-indexer.pem:/usr/share/wazuh-indexer/config/certs/wazuh-indexer.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-indexer-key.pem:/usr/share/wazuh-indexer/config/certs/wazuh-indexer-key.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-indexer/config/certs/root-ca.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer/wazuh.indexer.yml:/usr/share/wazuh-indexer/config/opensearch.yml:ro
networks: [security]
wazuh-manager:
image: wazuh/wazuh-manager:4.14.3
container_name: wazuh-manager
restart: unless-stopped
ulimits:
memlock: { soft: -1, hard: -1 }
nofile: { soft: 655360, hard: 655360 }
environment:
- INDEXER_URL=https://wazuh-indexer:9200
- INDEXER_USERNAME=admin
- INDEXER_PASSWORD=admin
- FILEBEAT_SSL_VERIFICATION_MODE=certificate
- SSL_CERTIFICATE_AUTHORITIES=/etc/ssl/root-ca.pem
- SSL_CERTIFICATE=/etc/ssl/filebeat.pem
- SSL_KEY=/etc/ssl/filebeat-key.pem
- API_USERNAME=wazuh-wui
- API_PASSWORD=MyS3cr37P450r.*-
ports:
- "1514:1514" # agent registration + event ingestion
- "1515:1515" # agent enrolment
- "514:514/udp" # syslog
- "55000:55000" # Wazuh API
volumes:
- wazuh_api_configuration:/var/ossec/api/configuration
- wazuh_etc:/var/ossec/etc
- wazuh_logs:/var/ossec/logs
- wazuh_queue:/var/ossec/queue
- wazuh_var_multigroups:/var/ossec/var/multigroups
- wazuh_integrations:/var/ossec/integrations
- wazuh_active_response:/var/ossec/active-response/bin
- wazuh_agentless:/var/ossec/agentless
- wazuh_wodles:/var/ossec/wodles
- wazuh_filebeat_etc:/etc/filebeat
- wazuh_filebeat_var:/var/lib/filebeat
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/root-ca-manager.pem:/etc/ssl/root-ca.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-manager.pem:/etc/ssl/filebeat.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-manager-key.pem:/etc/ssl/filebeat-key.pem:ro
# Mount Suricata's log dir so the manager can tail eve.json
- /mnt/nfs/docker/docker/suricata/logs:/var/log/suricata:ro
networks: [security]
wazuh-dashboard:
image: wazuh/wazuh-dashboard:4.14.3
container_name: wazuh-dashboard
restart: unless-stopped
ports:
- "5601:443"
environment:
- INDEXER_USERNAME=admin
- INDEXER_PASSWORD=admin
- WAZUH_API_URL=https://wazuh-manager
- DASHBOARD_USERNAME=kibanaserver
- DASHBOARD_PASSWORD=kibanaserver
- API_USERNAME=wazuh-wui
- API_PASSWORD=MyS3cr37P450r.*-
- RUN_AS=false
volumes:
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-dashboard.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/wazuh-dashboard-key.pem:/usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_indexer_ssl_certs/root-ca.pem:/usr/share/wazuh-dashboard/certs/root-ca.pem:ro
- /mnt/nfs/docker/docker/wazuh/config/wazuh_dashboard/opensearch_dashboards.yml:/usr/share/wazuh-dashboard/config/opensearch_dashboards.yml:ro
depends_on: [wazuh-indexer, wazuh-manager]
networks: [security, proxy]
suricata:
image: jasonish/suricata:latest
container_name: suricata
restart: unless-stopped
network_mode: host
cap_add: [NET_ADMIN, NET_RAW, SYS_NICE]
entrypoint: ["/bin/sh", "-c", "suricata-update && suricata -i ens18"]
volumes:
- suricata_lib:/var/lib/suricata
- /mnt/nfs/docker/docker/suricata/logs:/var/log/suricata
volumes:
suricata_lib:
wazuh_indexer_data:
wazuh_api_configuration:
wazuh_etc:
wazuh_logs:
wazuh_queue:
wazuh_var_multigroups:
wazuh_integrations:
wazuh_active_response:
wazuh_agentless:
wazuh_wodles:
wazuh_filebeat_etc:
wazuh_filebeat_var:
networks:
security:
driver: bridge
proxy:
external: true

Replace ens18 with your actual interface (ip -br a to find it). Suricata must run on host networking to see real packets — inside a Docker network it’d only see the container’s tiny virtual world.

The two config files pointed at by the compose mounts:

wazuh.indexer.yml
network.host: "0.0.0.0"
node.name: "wazuh-indexer"
cluster.initial_master_nodes: ["wazuh-indexer"]
cluster.name: "wazuh-cluster"
discovery.type: single-node
plugins.security.ssl.transport.pemcert_filepath: certs/wazuh-indexer.pem
plugins.security.ssl.transport.pemkey_filepath: certs/wazuh-indexer-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: certs/root-ca.pem
plugins.security.ssl.transport.resolve_hostname: false
plugins.security.ssl.transport.enforce_hostname_verification: false
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: certs/wazuh-indexer.pem
plugins.security.ssl.http.pemkey_filepath: certs/wazuh-indexer-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: certs/root-ca.pem
plugins.security.authcz.admin_dn:
- "CN=admin,OU=Wazuh,O=Wazuh,L=California,C=US"
plugins.security.nodes_dn:
- "CN=wazuh-indexer,OU=Wazuh,O=Wazuh,L=California,C=US"
plugins.security.allow_default_init_securityindex: true
plugins.security.restapi.roles_enabled: ["all_access", "security_rest_api_access"]
opensearch_dashboards.yml
server.host: 0.0.0.0
server.port: 443
opensearch.hosts: https://wazuh-indexer:9200
opensearch.ssl.verificationMode: certificate
opensearch.username: kibanaserver
opensearch.password: kibanaserver
opensearch.requestHeadersAllowlist: [authorization, securitytenant]
server.ssl.enabled: true
server.ssl.certificate: /usr/share/wazuh-dashboard/certs/wazuh-dashboard.pem
server.ssl.key: /usr/share/wazuh-dashboard/certs/wazuh-dashboard-key.pem
opensearch_security.multitenancy.enabled: true
opensearch_security.readonly_mode.roles: [kibana_read_only]
uiSettings.overrides.defaultRoute: /app/wz-home
Terminal window
cd /opt/stacks/security
docker compose up -d
# Wait ~2 minutes for the indexer's security plugin to initialise on first boot
docker logs -f wazuh-indexer | grep "security index"

Then hit https://<host>:5601. Browser warning (self-signed), accept, log in with admin / admin.

Change the admin password immediately:

Terminal window
docker exec -it wazuh-indexer bash -c "
export JAVA_HOME=/usr/share/wazuh-indexer/jdk
bash /usr/share/wazuh-indexer/plugins/opensearch-security/tools/wazuh-passwords-tool.sh \
-au admin -ap admin -a -A -n 'NewP4ssw0rd!'
"

Suricata writes eve.json to /var/log/suricata/eve.json (NDJSON — one JSON object per event). Because the compose mounts that directory into the Wazuh manager, you just need to tell ossec.conf to tail it:

edit via docker exec wazuh-manager vi /var/ossec/etc/ossec.conf
<ossec_config>
<localfile>
<log_format>json</log_format>
<location>/var/log/suricata/eve.json</location>
</localfile>
</ossec_config>

Restart the manager: docker exec wazuh-manager /var/ossec/bin/wazuh-control restart.

Wazuh ships with decoders for Suricata’s eve.json format out of the box (0470-suricata_decoders.xml) — alerts appear in the dashboard under their own rule group within minutes.

Host-side telemetry is what really earns Wazuh its keep. Install the agent on anything you care about — laptops, NAS, other VMs.

Terminal window
# On a Debian/Ubuntu client:
curl -s https://packages.wazuh.com/key/GPG-KEY-WAZUH | sudo apt-key add -
echo "deb https://packages.wazuh.com/4.x/apt/ stable main" | sudo tee /etc/apt/sources.list.d/wazuh.list
sudo apt update && sudo apt install wazuh-agent
sudo sed -i 's|<address>MANAGER_IP</address>|<address>192.168.1.248</address>|' \
/var/ossec/etc/ossec.conf
sudo systemctl enable --now wazuh-agent

Agents auto-enrol via port 1515 on first start. In the dashboard: AgentsDeploy new agent walks you through it with the right commands copy-pasteable.

SectionWhat’s there
Threat HuntingFree-text search over every alert. Start here.
Security EventsRule-level timeline. See authentication failures, firewall drops, custom rules firing.
Integrity Monitoring (FIM)File changes in watched directories (default: /etc, /bin, /usr/bin…)
Vulnerability DetectionCVE scan against installed packages on each agent
MITRE ATT&CKMaps events to MITRE tactics/techniques — useful for reporting

In Threat Hunting:

rule.level:>=10 # anything critical
rule.groups:authentication_failed AND agent.name:nas
rule.groups:suricata AND data.alert.severity:1 # Suricata high-sev only
data.srcip:185.156.73.* # all events from an ip range

Straight out of the box Wazuh will show you things like “ssh login succeeded for a valid user” as events. That’s correct but useless. Go to rules and raise thresholds, or disable noisy rules entirely.

Common targets on a homelab:

  • 5716 — “SSH authentication success” — level 3, usually fine to leave
  • 31100 / 31101 — web-traffic noise from CrowdSec’s bans
  • 533 — “listened ports status changed” — tripped by Docker’s churn

FIM is the single most useful Wazuh feature for a homelab. Watch a directory; any change (add / modify / delete) fires an alert with a diff.

In ossec.conf:

<syscheck>
<frequency>43200</frequency>
<directories check_all="yes" realtime="yes">/etc,/usr/bin,/usr/sbin</directories>
<directories check_all="yes" realtime="yes">/opt/stacks</directories>
<directories check_all="yes" report_changes="yes">/var/ossec/etc</directories>
<ignore>/etc/mtab</ignore>
<ignore>/etc/random-seed</ignore>
</syscheck>

realtime="yes" uses inotify for sub-second detection. report_changes="yes" includes a diff of the file content in the alert. The frequency value (seconds) is for periodic full rescans.

The dashboard is already HTTPS with a self-signed cert. To front it with your reverse proxy you need to tell Traefik to not verify the backend cert:

traefik dynamic config
http:
services:
wazuh:
loadBalancer:
servers:
- url: https://wazuh-dashboard:443
serversTransport: insecure-backend
serversTransports:
insecure-backend:
insecureSkipVerify: true

Pangolin handles this automatically when you mark the target as HTTPS in the resource config.

Stats.log keeps growing. Suricata’s stats.log is unbounded. Truncate it on a cron or lower the stats frequency in suricata.yaml (default 8s is noisy for a homelab).

Update Suricata rules. The image’s entrypoint calls suricata-update on every start. Restart Suricata weekly to pull fresh rules: docker compose restart suricata.

Update Wazuh. Pin the image tag (4.14.3 in the compose) and bump deliberately. Minor releases between Wazuh versions change index templates; a blind latest can break ingestion.

Backups. Everything stateful is in Docker volumes (listed at the bottom of the compose) + the NFS cert dir. docker run --rm -v wazuh_etc:/data -v $PWD:/backup alpine tar czf /backup/wazuh_etc.tgz -C /data . per volume, or include them in your borgmatic config.

Dashboard loads but shows “wazuh API not responding”. Manager is not up yet, or the dashboard’s API_PASSWORD doesn’t match the manager’s. Check docker logs wazuh-dashboard.

Manager logs say “filebeat: SSL handshake failed”. Cert SAN mismatch — set FILEBEAT_SSL_VERIFICATION_MODE=certificate (not full). Regenerate certs if you changed node IPs.

“No data” in any Wazuh dashboard tab. Go to Indexer management → Index patterns and confirm wazuh-alerts-* exists. If not, the manager isn’t shipping to the indexer — check docker logs wazuh-manager | grep filebeat.

Suricata alerts not appearing in Wazuh. Verify the mount (docker exec wazuh-manager ls -la /var/log/suricata/eve.json), verify ossec.conf has the <localfile> entry, and that the manager restarted after the edit.

Redirect loop in the dashboard. multitenancy setting mismatch between indexer and dashboard configs. Both must be true (or both false).

Can’t log in as admin. Wazuh 4.14.3’s INDEXER_PASSWORD env is bootstrap only. The live password is in internal_users.yml — default admin. Use the wazuh-passwords-tool.sh to rotate it properly.