A single Docker Compose stack running 15 containers with Terraform-managed Cloudflare infrastructure. Everything is Infrastructure as Code — no manual configuration, no secrets in version control.
graph TB
subgraph Internet
CF[Cloudflare CDN + WAF]
end
subgraph Cloudflare Zero Trust
ZT[Access Policies<br/>Email OTP Authentication]
end
subgraph External Services
Sonarr[Sonarr]
Radarr[Radarr]
Jellyfin[Jellyfin]
Deluge[Deluge]
end
subgraph Docker Host
subgraph tunnel[Tunnel Network]
CFD[cloudflared]
HP[Homepage<br/>Dashboard]
GF[Grafana]
N8N[n8n]
PORT[Portainer]
SPDF[Stirling PDF]
WD[Wazuh Dashboard]
GOT[Gotify]
end
subgraph tolonnet[Tolon Network]
TP[Tolon Site<br/>static nginx]
end
subgraph monitoring[Monitoring Network]
PROM[Prometheus]
NE[Node Exporter]
BE[Blackbox Exporter]
SE[Speedtest Exporter]
end
subgraph wazuh[Wazuh Network]
WI[Wazuh Indexer<br/>OpenSearch]
WM[Wazuh Manager]
end
AGENT[Wazuh Agent<br/>Host-level]
end
CF --> ZT --> CFD
CF -.->|public site, no Access| CFD
CFD --> HP & GF & N8N & PORT & SPDF & WD & GOT
CFD -->|tolon network| TP
HP -.->|widgets| GF & PORT & GOT & Sonarr & Radarr & Jellyfin & Deluge & CFD
GF --> PROM
PROM --> NE & BE & SE & N8N
WM --> WI
WD --> WI & WM
WM -->|alerts| N8N
N8N -->|push| GOT
AGENT -->|1514/1515| WM
BE -.->|probes| CF
graph LR
subgraph tunnel[tunnel network]
direction TB
cloudflared
homepage
grafana
n8n
portainer
stirling-pdf
wazuh-dashboard
blackbox-exporter
gotify
end
subgraph tolon[tolon network]
direction TB
tolon-site
end
subgraph monitoring[monitoring network]
direction TB
prometheus
node-exporter
speedtest-exporter
end
subgraph wazuh[wazuh network]
direction TB
wazuh-indexer
wazuh-manager
end
cloudflared -.->|also joins| tolon
homepage -.-> monitoring
grafana -.-> monitoring
n8n -.-> monitoring
n8n -.-> wazuh
blackbox-exporter -.-> wazuh
Services connect to multiple networks as needed. Only cloudflared faces the internet. tolon-site is not on the tunnel network: it has its own tolon network, shared only with cloudflared, so no other service can reach it. Prometheus, Node Exporter, and Speedtest Exporter have no tunnel access.
The alert notification system uses a batch-and-triage architecture. Alerts are stored in an n8n Data Table and processed hourly with Gemini AI analysis, then delivered via email and push notification.
sequenceDiagram
participant Agent as Wazuh Agent<br/>(Host)
participant Manager as Wazuh Manager
participant Webhook as n8n Webhook
participant DB as n8n Data Table
participant Schedule as Hourly Trigger
participant Gemini as Gemini AI
participant Email as Gmail SMTP
participant Gotify as Gotify Push
Agent->>Manager: Security event (syslog, FIM, etc.)
Manager->>Manager: Correlate & classify (rule engine)
alt Alert level >= 10
Manager->>Webhook: POST /webhook/wazuh-alerts (JSON)
Webhook->>DB: Store alert (processed=false)
end
Note over Schedule,Gotify: Every hour
Schedule->>DB: Query unprocessed alerts
alt Has unprocessed alerts
DB->>Gemini: Batch alerts for AI analysis
Gemini->>Gemini: Severity assessment, MITRE mapping,<br/>executive summary
Gemini->>Email: HTML digest + Markdown attachment
Gemini->>Gotify: Push notification with threat level
Gemini->>DB: Mark alerts as processed
end
Pipeline nodes (16 total, single workflow with dual triggers):
| Path | Nodes |
|---|---|
| Ingest | Wazuh Alert Webhook -> Extract Alert Fields -> Store in Data Table |
| Digest | Hourly Trigger -> Get Unprocessed Alerts -> Has Alerts? -> Prepare Prompt -> AI Analysis (Gemini) -> Parse Response -> Build Email & Markdown -> Send Email + Gotify Push + Mark Processed -> Update Data Table |
graph TD
subgraph Cloudflare
TUNNEL[Zero Trust Tunnel]
DNS[DNS CNAME Records]
ACCESS[Access Applications<br/>+ OTP Policies]
WAF[Rate Limiting Rules]
end
subgraph Managed Resources
R1[cloudflare_zero_trust_tunnel_cloudflared]
R2[cloudflare_zero_trust_tunnel_cloudflared_config]
R3["cloudflare_dns_record (x8)"]
R4["cloudflare_zero_trust_access_application (x6)"]
R7["cloudflare_ruleset http_ratelimit (opt-in)"]
end
R1 --> TUNNEL
R2 --> TUNNEL
R3 --> DNS
R4 --> ACCESS
R7 --> WAF
DNS -->|CNAME| TUNNEL
ACCESS -->|protects| TUNNEL
Terraform manages 17 always-on resources: the random tunnel secret, the tunnel, its ingress config, 8 DNS records, and 6 Zero Trust access apps. Each app covers a whole hostname and requires email OTP before reaching the application. Gotify uses token-based authentication only (the Android app cannot handle Cloudflare Access OTP).
tolon.<domain> is a public static site with no Access app (see apps/tolon-site/README.md). Two groups of resources are conditional:
| File | Resource | Switch | API token permission |
|---|---|---|---|
ratelimit.tf |
http_ratelimit ruleset: 100 requests per 10 s per IP and colo on the Tolon host, block for 10 s (the free plan allows one rule). Clients see Cloudflare error 1015. |
enable_tolon_rate_limit = true |
Zone > Zone WAF > Edit |
email_routing.tf |
Email Routing settings, DNS, the verified admin address and a rule that forwards tolon@<domain> to the admin address |
enable_tolon_contact = true |
Zone > Email Routing Rules > Edit, Account > Email Routing Addresses > Edit |
Both switches are on in the real terraform.tfvars. Email Routing adds the MX and SPF records at the zone apex, and Cloudflare creates its own DKIM record, which is not in Terraform. The token in use also has Zone > Zone Settings > Edit.
The state was written by OpenTofu 1.11, so run that version through Docker rather than a locally installed binary:
alias tofu='docker run --rm -it -v "$PWD/terraform":/work -w /work ghcr.io/opentofu/opentofu:1.11'
tofu plan -target=cloudflare_dns_record.tunnel -target=cloudflare_zero_trust_tunnel_cloudflared_config.homelabApplies are targeted. The Grafana Access app has drift that is still open: an allow-mcp policy was created outside Terraform, and a full apply would try to revert it. Keep applies targeted until the drift is resolved (by importing the policy into Terraform or by removing it). Always pass -target for the resources you mean to change, and read the plan before applying. Back up terraform.tfstate first (the pre-*.tfstate files in terraform/ are such copies, and are gitignored). State files have mode 600, because they hold secrets.
| Service | Purpose | Public | Port |
|---|---|---|---|
| cloudflared | Cloudflare Tunnel ingress | Gateway | - |
| homepage | Dashboard with service widgets | https://domain.com |
- |
| portainer | Container management | https://portainer.domain.com |
- |
| stirling-pdf | PDF manipulation tools | https://pdf.domain.com |
- |
| gotify | Push notifications | https://gotify.domain.com |
- |
| n8n | Workflow automation | https://n8n.domain.com |
- |
| grafana | Metrics visualization | https://grafana.domain.com |
- |
| prometheus | Metrics storage | Internal only | - |
| node-exporter | Host metrics collector | Internal only | - |
| blackbox-exporter | HTTP/ICMP probes | Internal only | - |
| speedtest-exporter | Internet speed metrics | Internal only | - |
| wazuh-indexer | OpenSearch alert storage | Internal only | - |
| wazuh-manager | SIEM analysis engine | Internal only | 1514, 1515, 55000 |
| wazuh-dashboard | Security monitoring UI | https://wazuh.domain.com |
- |
| tolon-site | Static results page of the Tolon icon poll (details) | https://tolon.domain.com |
- |
Every public service is protected by two layers:
- Cloudflare Zero Trust — email OTP gate before any traffic reaches the server
- Application-level login — each service has its own credentials
Exception, Tolon site: tolon.<domain> has no Access gate. It is a static page with no backend, no database and no forms. nginx adds per-IP request limits, the security headers and a Cloudflare edge rate limit. See apps/tolon-site/README.md.
Exception: Gotify is protected by Cloudflare Tunnel (no exposed ports) + WAF/DDoS protection + its built-in token authentication, but does not have a Cloudflare Access policy. The Gotify Android app makes direct HTTP/WebSocket calls and cannot handle the email OTP flow. Registration is disabled (GOTIFY_REGISTRATION=false).
All containers follow a hardened baseline:
security_opt: [no-new-privileges:true]cap_drop: [ALL]— only add back capabilities that are strictly requiredread_only: truewithtmpfsmounts where possible- No containers expose ports to the host except Wazuh Manager (agent enrollment)
tolon-siteruns as the unprivileged nginx user on its owntolonnetwork, shared only withcloudflared
- Docker: All secrets live in
.env(gitignored). Config files use${VAR}substitution. - Terraform: Sensitive values in
terraform.tfvars(gitignored). API token markedsensitive = true. - Homepage: Uses
{{HOMEPAGE_VAR_*}}template syntax — credentials injected via environment variables at runtime. - Portainer: Admin password stored as a Docker secret (
./secrets/). - Wazuh: TLS certificates in
files/wazuh/certs/(gitignored).
Some configuration files are version-controlled but encrypted at rest in the repository using git-crypt. These files contain security-sensitive data (e.g., Wazuh detection suppression rules that reveal monitoring blind spots) and appear as binary blobs in the repo unless unlocked.
Currently encrypted files:
files/wazuh/rules/local_rules.xml— Custom Wazuh rules that suppress known false positives
How it works:
- Files matching patterns in
.gitattributesare transparently encrypted on commit and decrypted on checkout - The working tree always contains plaintext (when unlocked) — Docker can mount these files normally
- The symmetric key is stored in
.git/git-crypt/keys/default(never pushed) and should be backed up externally
Recovery (fresh clone):
# 1. Install git-crypt
sudo apt-get install -y git-crypt
# 2. Clone the repository
git clone https://github.com/danielpsf/homelab.git
cd homelab
# 3. Restore the key from your external backup (decode the base64-encoded key back to a file)
echo '<base64-key>' | base64 -d > /tmp/homelab-git-crypt-key
# 4. Unlock the repository
git-crypt unlock /tmp/homelab-git-crypt-key
# 5. Verify and clean up
git-crypt status # should show "encrypted:" for protected files
file files/wazuh/rules/local_rules.xml # should show "XML" or "text"
rm /tmp/homelab-git-crypt-keyTo add more encrypted files: Add a pattern to .gitattributes, then git rm --cached <file> && git add <file> to re-stage it through the encryption filter.
This project includes a custom OpenCode skill that turns natural language requests into full-depth security investigations. When a Wazuh alert digest arrives or a manual security review is needed, the security-analyst skill orchestrates a multi-phase assessment:
- Proactive Attack Surface Audit — scans network exposure, container hardening, authentication posture, firewall state, and host patch level before even reading the alert digest
- Alert Parsing & Correlation — extracts structured fields from every alert, cross-references a local false positive catalog, groups related alerts by time and source, maps them to MITRE ATT&CK, and checks historical trends via the n8n data table
- Live Investigation — dispatches parallel agents to investigate each alert group on the running system, collecting evidence and assessing compensating controls (network isolation, zero-trust policies, capability restrictions, read-only filesystems)
- Vulnerability Assessment — scans all container images and the host filesystem with Trivy (pinned to a verified safe version with a supply chain safety protocol addressing CVE-2026-33634), cross-references findings against the CISA Known Exploited Vulnerabilities catalog and NVD, and reviews Wazuh SCA/CIS benchmark results
- Synthesis & Reporting — assembles a structured security assessment with executive summary, threat matrix, detailed findings, prioritized remediation plan, Wazuh tuning recommendations, and active response proposals
- Supply Chain Assessment — audits container image freshness, tag pinning, and registry trust
All investigation outputs — reports, scan results, and the false positive catalog — are stored locally in .local/security/ and never committed to version control. The false positive catalog is maintained as a YAML file that accumulates institutional knowledge across investigations, reducing noise over time without embedding environment-specific data in the skill itself.
The skill is defined in .opencode/skills/security-analyst/SKILL.md and can be triggered by providing a Wazuh digest file or asking about the homelab's security posture.
homelab/
├── docker-compose.yml # All 15 services
├── .env.sample # Environment template (copy to .env)
├── .gitattributes # git-crypt encryption patterns
├── .gitignore
├── .opencode/
│ └── skills/
│ └── security-analyst/ # OpenCode skill for Wazuh alert investigation
│ └── SKILL.md
├── .local/ # Local-only security data (gitignored)
│ └── security/
│ ├── false-positives.yml # Known FP catalog (auto-maintained)
│ ├── investigations/ # Dated investigation reports
│ └── scans/ # Trivy and CVE scan results
├── apps/
│ └── tolon-site/ # Static results page of the Tolon icon poll (see its README)
│ ├── nginx.conf # Headers, caching, rate limit
│ ├── web/ # Results page, /preview/
│ └── preview-build/ # build.sh: browser build of the real Tolon UI
├── data/ # Runtime data (gitignored)
├── secrets/ # Docker secrets (gitignored contents)
├── files/
│ ├── grafana/
│ │ └── provisioning/
│ │ ├── dashboards/ # 4 pre-built dashboards
│ │ │ ├── homelab-overview.json
│ │ │ ├── internet-connection.json
│ │ │ ├── n8n-system-health.json
│ │ │ └── node-exporter-host.json
│ │ └── datasources/
│ │ └── datasource.yml
│ ├── homepage/
│ │ ├── services.yaml # Dashboard service definitions
│ │ ├── settings.yaml # Layout configuration
│ │ ├── bookmarks.yaml
│ │ └── docker.yaml # Docker socket config
│ ├── n8n/
│ │ └── workflows/
│ │ └── wazuh-ai-triage.json # Sanitized workflow (import into n8n)
│ ├── prometheus/
│ │ ├── prometheus.yml # Scrape configs
│ │ ├── blackbox.yml # Probe configuration
│ │ └── pinghosts.yaml # ICMP targets
│ └── wazuh/
│ ├── ossec.conf # Manager configuration
│ ├── integrations/
│ │ └── custom-n8n # Alert -> n8n webhook script
│ ├── rules/
│ │ └── local_rules.xml # Custom suppression rules (git-crypt encrypted)
│ ├── certs/ # TLS certificates (gitignored)
│ ├── indexer/
│ │ ├── opensearch.yml
│ │ └── internal_users.yml # Bcrypt password hashes
│ └── dashboard/
│ └── entrypoint.sh
└── terraform/
├── main.tf # Tunnel + ingress config
├── dns.tf # CNAME records
├── access.tf # Zero Trust access policies
├── ratelimit.tf # Opt-in edge rate limit
├── variables.tf
├── outputs.tf
├── versions.tf
└── terraform.tfvars.sample # Terraform variables template
- Docker and Docker Compose
- OpenTofu or Terraform
- git-crypt (for decrypting sensitive config files)
- A Cloudflare account with a domain
- (Optional) Google Gemini API key for AI-powered alert triage
- (Optional) SMTP credentials for email notifications
- (Optional) Gotify Android/iOS app for push notifications
git clone https://github.com/danielpsf/homelab.git
cd homelab
# Decrypt sensitive config files (see "Encrypted Files" section for key recovery)
git-crypt unlock /path/to/homelab-git-crypt-key
# Docker environment
cp .env.sample .env
# Edit .env with your values (domain, passwords, API keys, etc.)
# Terraform variables
cp terraform/terraform.tfvars.sample terraform/terraform.tfvars
# Edit terraform.tfvars with Cloudflare credentialsFollow the Wazuh Docker deployment guide to generate TLS certificates, then place them in files/wazuh/certs/.
# External volumes for persistent data
docker volume create homelab_prometheus_data
docker volume create homelab_grafana_data
# Portainer admin password (Docker secret)
mkdir -p secrets
docker run --rm httpd:2-alpine htpasswd -nbB admin 'YourPassword' | cut -d: -f2 > secrets/portainer_admin_password# Initialize and apply Terraform
cd terraform
tofu init # or: terraform init
tofu apply # or: terraform apply (see "Running OpenTofu" for targeted applies)
# Copy the tunnel token from output to .env (CLOUDFLARE_TUNNEL_TOKEN)
cd ..docker compose up -d- Open Gotify at
https://gotify.<your-domain>and log in with the admin credentials from.env - Create an Application (e.g., "n8n Wazuh Alerts") — copy the app token for the n8n workflow
- Create a Client (e.g., "Homepage Widget") — copy the client token to
HOMEPAGE_VAR_GOTIFY_KEYin.env - Install the Gotify Android app and log in with the server URL and admin credentials
- Open n8n at
https://n8n.<your-domain> - Create SMTP and Google Gemini credentials
- Create a Data Table named
wazuh_alertswith columns:alert_id(string),rule_id(string),rule_level(number),rule_description(string),agent_name(string),timestamp(string),full_log(string),location(string),decoder(string),rule_groups(string),alert_json(string),processed(boolean),received_at(string) - Import
files/n8n/workflows/wazuh-ai-triage.json - Update placeholder values:
YOUR_DATA_TABLE_ID,YOUR_GEMINI_CREDENTIAL_ID,YOUR_SMTP_CREDENTIAL_ID,YOUR_GOTIFY_APP_TOKEN, and the email addresses - Activate the workflow
No setup beyond docker compose up -d tolon-site. The page is static; see apps/tolon-site/README.md.
Install the Wazuh agent on the host to monitor the system itself:
# Follow https://documentation.wazuh.com/current/installation-guide/wazuh-agent/
# Point the agent to localhost:1514Four pre-provisioned dashboards are included:
- Homelab Overview — container status, service health, resource usage
- Internet Connection — latency, packet loss, uptime probes, speedtest history
- n8n System Health — workflow executions, errors, API latency
- Node Exporter Host — CPU, memory, disk, network metrics
The files/wazuh/indexer/internal_users.yml file contains bcrypt-hashed passwords. When deploying your own instance, regenerate them:
# Generate a bcrypt hash for your password
docker run --rm -it amazon/opendistro-for-elasticsearch \
/usr/share/elasticsearch/plugins/opendistro_security/tools/hash.sh -p 'YourNewPassword'Replace the hash values in internal_users.yml with the output.
This project is provided as-is for educational and personal use.