Monitoring a Cloud Server with a Self-Hosted Wazuh SIEM: Without Exposing It to the Internet
How we connect an AWS production box to a homelab Wazuh manager over a WireGuard tunnel, then act on what it finds: zero exposed SIEM ports, CVEs driven to zero, and CIS hardening from 42% to 78%.

Running your own SIEM (in our case Wazuh, the open-source SIEM and host-based intrusion detection platform) is one of the highest-leverage moves a small team can make: full visibility into file changes, logins, and rootkit indicators across your fleet, with none of the per-GB ingestion bills. We run the Wazuh manager on a small VM in our homelab.
But self-hosting creates a routing problem. Our production application runs on a cloud server. The Wazuh manager lives on a private LAN behind a home router. It has no public IP, and we very much want to keep it that way. So how does an agent on a public cloud box send its events to a manager it can't even address?
The wrong answer is "port-forward the SIEM to the internet." A SIEM is the last thing you want exposed. The right answer is a private tunnel: the agent talks to the manager's internal address as if it were on the same LAN, over an encrypted WireGuard link. This post walks through exactly how we set it up, including the one diagnostic detail that trips everyone up.
TL;DR: To monitor a cloud server with a self-hosted Wazuh SIEM whose manager has no public IP, run the agent over a WireGuard tunnel to the manager's private LAN IP, so no SIEM ports are ever exposed to the internet. Test connectivity with TCP 1514/1515, not
ping(a hardened host drops ICMP), and pin the agent to the manager's exact version. Then act on what the SIEM surfaces: clear CVEs by advancing the Amazon Linux 2023 release snapshot (dnf upgrade --releasever=latest), and harden the host against the CIS benchmark via Wazuh SCA.
The architecture
Cloud server (prod) Homelab
┌──────────────────┐ WireGuard ┌─────────────────────────────┐
│ Wazuh agent │ tunnel │ WireGuard gateway │
│ wg: 10.8.0.10 ──┼───────────────┼─► 10.8.0.1 (LAN: 10.0.0.5) │
│ │ UDP/51820 │ │ NAT to LAN │
│ → 10.0.0.20 │ │ └─► Wazuh manager │
└──────────────────┘ │ 10.0.0.20 │
└─────────────────────────────┘
The key idea: the cloud agent is configured to reach the manager at its private LAN IP (10.0.0.20). That traffic is routed into the WireGuard tunnel, and the gateway NATs it onto the LAN. The manager never knows or cares that the agent is in the cloud, and nothing about the SIEM is ever exposed publicly.
A nice property of this design is that the manager needs zero changes. All the routing intelligence lives on the gateway, which most homelabs already run for remote access.
Prerequisites
- A running Wazuh manager on your LAN (we're on v4.14.2).
- A WireGuard gateway on the same LAN that already lets remote peers in (a Proxmox LXC, a Raspberry Pi, an OPNsense box, anything). Crucially, it should already do NAT/masquerade for its tunnel subnet, e.g.:
PostUp = iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE PostUp = iptables -A FORWARD -i wg0 -o eth0 -j ACCEPT PostUp = iptables -A FORWARD -i eth0 -o wg0 -m state --state RELATED,ESTABLISHED -j ACCEPT - A stable public endpoint for the gateway (a static IP or, like us, a DDNS hostname).
- Root on the cloud server (this example uses an RPM-based distro, Amazon Linux 2023).
Step 1: Add the cloud server as a WireGuard peer
On the cloud server, install the tools and generate a keypair plus a preshared key:
sudo dnf install -y wireguard-tools
sudo bash -c '
cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
wg genpsk > server_preshared.key
echo "=== PUBLIC KEY ==="; cat server_public.key
echo "=== PRESHARED KEY ==="; cat server_preshared.key
'
On the WireGuard gateway, register the new peer (assign it the next free tunnel IP, here 10.8.0.10) and apply the change live without dropping your other peers:
cat >> /etc/wireguard/wg0.conf <<'EOF'
[Peer]
# Cloud prod server (wazuh agent)
PublicKey = <CLOUD_SERVER_PUBLIC_KEY>
PresharedKey = <PRESHARED_KEY>
AllowedIPs = 10.8.0.10/32
EOF
wg syncconf wg0 <(wg-quick strip wg0)
Back on the cloud server, write the tunnel config. The detail that makes this whole thing work is AllowedIPs: it must include the manager's address so that traffic is routed through the tunnel.
# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = <CLOUD_SERVER_PRIVATE_KEY>
Address = 10.8.0.10/32
[Peer]
PublicKey = <GATEWAY_PUBLIC_KEY>
PresharedKey = <PRESHARED_KEY>
Endpoint = vpn.example-homelab.net:51820
AllowedIPs = 10.8.0.0/24, 10.0.0.20/32
PersistentKeepalive = 25
We scope AllowedIPs to just the tunnel subnet plus the manager's /32, so only SIEM traffic uses the tunnel; everything else on the server is untouched. PersistentKeepalive keeps the NAT path open since the cloud server is the side dialing in.
Bring it up and confirm the handshake:
sudo systemctl enable --now wg-quick@wg0
sudo wg show
A healthy peer shows a recent latest handshake and non-zero received bytes. If you see 0 B received, the gateway isn't answering, almost always a key/preshared-key mismatch or the peer not being synced yet.
Step 2: The firewall trap (ping lies to you)
Here's where most people lose an hour. With the tunnel up, the natural test is:
ping -c3 10.0.0.20
# 100% packet loss
Panic. Except: ping failing does not mean the path is broken. A properly locked-down SIEM host drops everything it isn't explicitly told to accept, and ICMP is rarely on that list. Our manager's firewall looks like this:
| Action | Proto | Port | Purpose |
|---|---|---|---|
| ACCEPT | tcp | 1515 | Agent enrollment |
| ACCEPT | tcp | 1514 | Agent data |
| ACCEPT | tcp | 443 | Dashboard (LAN only) |
| ... | ... | ... | ... |
| DROP | all | all | Block everything else |
There's no rule for ICMP, so ping hits the final DROP. But the agent doesn't use ICMP; it uses TCP 1514 and 1515. So test those instead:
(echo > /dev/tcp/10.0.0.20/1515) 2>/dev/null && echo "1515 OPEN" || echo "1515 BLOCKED"
(echo > /dev/tcp/10.0.0.20/1514) 2>/dev/null && echo "1514 OPEN" || echo "1514 BLOCKED"
If those come back OPEN, the tunnel and firewall are perfect and you can ignore the failed ping entirely. Always test the port you actually need, not ICMP.
Step 3: Install the agent (version-pinned)
Always match the agent version to your manager; an agent newer than its manager is unsupported. Check the manager first:
sudo /var/ossec/bin/wazuh-control info | grep VERSION
# WAZUH_VERSION="v4.14.2"
Then on the cloud server, add the repo and install that exact version. Note the version is pinned through the package name, not the repo URL (the repo path is always 4.x/yum/):
rpm --import https://packages.wazuh.com/key/GPG-KEY-WAZUH
cat > /etc/yum.repos.d/wazuh.repo <<'EOF'
[wazuh]
gpgcheck=1
gpgkey=https://packages.wazuh.com/key/GPG-KEY-WAZUH
enabled=1
name=EL-$releasever - Wazuh
baseurl=https://packages.wazuh.com/4.x/yum/
protect=1
EOF
WAZUH_MANAGER="10.0.0.20" \
WAZUH_AGENT_NAME="prod-cloud-01" \
dnf install -y wazuh-agent-4.14.2
The WAZUH_MANAGER env var is the manager's private LAN IP; the agent reaches it over the tunnel. Start it and watch the log:
systemctl enable --now wazuh-agent
sleep 8
grep -E "Valid key|Connected to the server" /var/ossec/logs/ossec.log | tail
The two lines you want:
wazuh-agentd: INFO: Valid key received
wazuh-agentd: INFO: (4102): Connected to the server ([10.0.0.20]:1514/tcp).
That's enrollment plus a live data channel. The agent is now reporting.
Step 4: Tell the agent what to watch
A freshly enrolled agent only runs default checks. We add a second <ossec_config> block (Wazuh merges multiple root blocks, so this never disturbs the enrollment config) to monitor what actually matters on an app server:
<ossec_config>
<!-- Docker container logs: backend, frontend, nginx, redis -->
<localfile>
<log_format>json</log_format>
<location>/var/lib/docker/containers/*/*-json.log</location>
</localfile>
<!-- systemd journal: sshd, docker, system services -->
<localfile>
<log_format>journald</log_format>
<location>journald</location>
</localfile>
<!-- File Integrity Monitoring -->
<syscheck>
<disabled>no</disabled>
<frequency>3600</frequency>
<scan_on_start>yes</scan_on_start>
<directories check_all="yes" realtime="yes">/etc</directories>
<directories check_all="yes" realtime="yes">/opt/app</directories>
</syscheck>
<!-- Rootkit / anomaly detection -->
<rootcheck>
<disabled>no</disabled>
<check_trojans>yes</check_trojans>
<check_ports>yes</check_ports>
<check_pids>yes</check_pids>
</rootcheck>
</ossec_config>
Restart, confirm it's healthy, and check for config errors:
systemctl restart wazuh-agent
systemctl is-active wazuh-agent
grep -iE "error|invalid" /var/ossec/logs/ossec.log | tail
Then open the Wazuh dashboard → Endpoints, and your new agent should appear as Active, complete with file-integrity events and rootcheck results.
Step 5: Reading Vulnerability Detection (and the trap that comes with it)
Once the agent is reporting, enable Vulnerability Detection on the manager and the dashboard fills with CVEs scored against the agent's installed packages. Ours opened with a sobering number: 459 High. The instinct is to start patching, but on Amazon Linux 2023 that instinct walks straight into a trap.
AL2023 uses deterministic, versioned releases. Your box is pinned to a
specific repo snapshot via the system-release package (e.g.
2023.10.20260120), and dnf resolves everything against that snapshot. So:
sudo dnf check-update
# exits 0 (nothing to do)
…reports nothing pending even when months of security fixes exist upstream,
because there genuinely is nothing newer within the locked snapshot. Meanwhile
the SIEM keeps flagging the CVEs, because the installed builds (python3-3.9.21,
grub2-…0.3, and friends) really are the vulnerable ones. You can dnf update
all day and the High count won't move.
The fix is to advance the snapshot, not to patch in place:
# 1. Preview what advancing pulls in (no changes made):
sudo dnf upgrade --releasever=latest --assumeno | tail -50
# 2. Snapshot/AMI the box first (this is a large, multi-package OS jump).
# 3. Apply and reboot into the new release:
sudo dnf upgrade --releasever=latest -y && sudo reboot
For us that single command pulled 108 package upgrades (kernel, glibc,
openssl, python3, runc, containerd, the works) and moved
system-release forward four months. After the reboot, kick the agent so it
re-reports the patched inventory:
sudo systemctl restart wazuh-agent
The High count dropped from 459 to 224.
The last 224: don't forget the old kernel
Everything still flagged was a single package: kernel. The reason is that
AL2023 keeps the previous kernel installed after an upgrade, and Wazuh scans
every installed package, including the superseded one you're no longer
booting:
rpm -q kernel6.12
# kernel6.12-6.12.64-… <- old, still installed, still flagged
# kernel6.12-6.12.88-… <- new, currently running
Remove the old kernel, restart the agent, and let the manager rescan:
sudo dnf remove kernel6.12-6.12.64-87.122.amzn2023 # the OLD version only
sudo systemctl restart wazuh-agent
Final tally: 459 → 224 → 0 across Critical/High/Medium/Low.
The lesson worth keeping: on a deterministic-release distro, a clean
vulnerability dashboard is a function of which snapshot you're pinned to, not
how often you run dnf update. Fold dnf upgrade --releasever=latest into your
regular patch cadence (containers come back on their own with
restart: unless-stopped), and prune old kernels so you're not scored against a
build you don't even run.
Step 6: Configuration hardening with SCA (the CIS Benchmark)
Vulnerability Detection tells you about vulnerable packages. Wazuh's Security Configuration Assessment (SCA) tells you about insecure settings: it scores the host against a benchmark like CIS Amazon Linux 2023. Our first scan came back at 42% (≈100 failed checks). Here's how we took it to ~78%, and (just as important) why we stopped there instead of chasing 100%.
We worked in reversible, idempotent batches, re-scanning after each:
- Quick wins (no risk). Disable unused filesystem/USB kernel modules
(
cramfs,udf,usb-storage…) via amodprobe.ddrop-in; harden/dev/shmmount options. → 45%. - Config hardening. SSH (
MaxAuthTries, banner, idle timeout, X11 off), auditd rules, rsyslog/journald, cron permissions, sudo (use_pty, logfile), login banners, password-aging inlogin.defs, core-dump disable, AIDE. → 70%. - Tuning pass. The first rescan always leaves stragglers: a chrony time source, an rsyslog drop-in the rule wanted in a second location, audit rules that needed a missing watch. → 76%.
- PAM/auth.
pam_pwquality,pam_pwhistory,pam_faillock, restrictingsuto thewheelgroup. This one edits the auth stack, so we did it with a second SSH session held open and testedsudobefore trusting it. → 78%.
Three lessons that save hours
Validate before you reload. Anything touching sshd or sudoers gets a
syntax check first (sshd -t, visudo -c), and we reload sshd rather than
restart it so existing sessions survive a mistake. For PAM (which has no dry-run),
keep a root session open and verify a fresh login works before you close it.
Not every failed check should be "fixed". A big chunk of CIS assumes a
machine shape that doesn't fit a containerized cloud box. The benchmark wants
separate partitions for /var, /var/log, /home, etc., which is a storage
re-architecture, not a config tweak. It wants an on-host firewall, but the
cloud provider's security groups already do deny-by-default. It wants SELinux
enforcing, which is risky on a Docker host without a tested relabel. We documented
these as accepted risk rather than pretending they were oversights.
Some benchmark rules are just wrong for your system. Several checks were
unpassable not because the box was misconfigured, but because the rule was:
one audit rule required create_module/query_module syscalls that were
removed in modern kernels; a core-dump check insisted on Storage=0, which
isn't a valid value (the correct setting is Storage=none); one rule's regex was
inverted and fails on a correctly shadowed password file. The right response is
to verify the actual setting is secure, then note the discrepancy, not to
contort the system to satisfy a buggy assertion.
The takeaway: a hardening score is a guide, not a target. On a single-volume, containerized cloud host, ~80% with every exception consciously documented is a stronger position than 100% achieved by breaking things or gaming checks.
Hardening notes
A few things we'd flag for any production rollout:
Enrollment password. Default enrollment is passwordless. It's low-risk here because the manager is only reachable through the tunnel and a LAN-restricted firewall, but for defense-in-depth, enable
use_passwordand anauthd.passon the manager, then re-enroll.
Mind your DDNS. If your gateway's public endpoint is a dynamic-DNS name, make sure the updater runs on something always-on. If that record goes stale, the tunnel can't re-establish after an IP change.
Least-privilege
AllowedIPs. Routing only the manager's/32over the tunnel (rather than the whole LAN) keeps the blast radius small if the cloud server is ever compromised.
Frequently asked questions
Can a Wazuh agent connect to a manager that has no public IP? Yes. Run the agent's traffic over a WireGuard tunnel to the manager's private LAN IP. The WireGuard gateway NATs that traffic onto the LAN, so the manager sees an ordinary LAN client; it needs no public exposure and no configuration changes.
What ports does a Wazuh agent use?
TCP 1515 for enrollment (authd) and TCP 1514 for the ongoing data channel. Confirm reachability with a TCP test to those ports, not with ping.
Why does ping to the Wazuh manager fail even though the agent connects fine?
Because a hardened host drops ICMP by default. ping failing tells you nothing about the agent path; the agent only needs TCP 1514/1515. Test those ports directly (e.g. echo > /dev/tcp/HOST/1514) instead of relying on ICMP.
Why won't my Amazon Linux 2023 CVEs clear after running dnf update?
AL2023 uses deterministic, versioned releases; your box is pinned to a system-release snapshot, so dnf finds nothing newer within it even when fixes exist upstream. Advance the snapshot with sudo dnf upgrade --releasever=latest, reboot, then prune the old kernel so the scanner stops flagging a build you no longer run.
What CIS benchmark score should I target on a cloud server?
Aim for a high score with documented exceptions, not 100%. Many CIS checks (separate /var and /home partitions, an on-host firewall, SELinux enforcing) assume a machine shape that doesn't fit a single-volume, containerized cloud host, and some rules are simply wrong for a modern kernel. Verify the setting is actually secure, then record the exception.
Wrapping up
The pattern generalizes well beyond Wazuh: any time a cloud node needs to reach a private service, a scoped WireGuard tunnel beats poking holes in a firewall. You get an encrypted path, no public exposure, and (because the agent just talks to a "LAN" IP) none of the components need special cloud-awareness.
And if you remember one thing from this post: when the tunnel is up but ping fails, test the actual TCP port before you start debugging. Nine times out of ten, it was working the whole time.
Want a hand setting up self-hosted security monitoring for your stack? Get in touch. We'd love to help.