A single Docker Compose stack running 16 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<br/>DNSSEC, TLS 1.2+, DMARC, CAA]
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 sitenet[Site Network]
SITE[Personal Site<br/>danielpsf.net, 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 sites, no Access| CFD
CFD --> HP & GF & N8N & PORT & SPDF & WD & GOT
CFD -->|tolon network| TP
CFD -->|site network| SITE
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 public sites| 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 site[site network]
direction TB
site-nginx[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
cloudflared -.->|also joins| site
homepage -.-> monitoring
grafana -.-> monitoring
n8n -.-> monitoring
n8n -.-> wazuh
blackbox-exporter -.-> wazuh
Services connect to multiple networks as needed. Only cloudflared faces the internet. The two public static sites are not on the tunnel network: tolon-site has its own tolon network and site (the personal site at the apex) has its own site network, each shared only with cloudflared, so no other service can reach them. 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[WAF: Free Managed Ruleset,<br/>custom rules, rate limit]
ZONE[Zone: DNSSEC, TLS 1.2+,<br/>Always HTTPS, Always Online]
MAIL[DMARC + CAA records]
end
subgraph Managed Resources
R1[cloudflare_zero_trust_tunnel_cloudflared]
R2[cloudflare_zero_trust_tunnel_cloudflared_config]
R3["cloudflare_dns_record tunnel (x10)"]
R4["cloudflare_zero_trust_access_application (x6)"]
R7["cloudflare_ruleset http_ratelimit (opt-in)"]
R8["cloudflare_ruleset firewall managed + custom"]
R9["cloudflare_zone_dnssec + cloudflare_zone_setting (x3)"]
R10["cloudflare_dns_record DMARC + CAA (x6)"]
end
R1 --> TUNNEL
R2 --> TUNNEL
R3 --> DNS
R4 --> ACCESS
R7 --> WAF
R8 --> WAF
R9 --> ZONE
R10 --> MAIL
DNS -->|CNAME| TUNNEL
ACCESS -->|protects| TUNNEL
Terraform manages 31 always-on resources: the random tunnel secret, the tunnel, its ingress config, 10 tunnel DNS records, 6 Zero Trust access apps, and 12 hardening resources in hardening.tf (DNSSEC, 3 zone settings, DMARC, 5 CAA records, the managed and custom WAF rulesets). 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).
The apex <domain> (with www. redirecting to it) and tolon.<domain> are public static sites with no Access app (see apps/tolon-site/README.md). The Homepage dashboard lives at home.<domain>. 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 public static hosts (apex, www., tolon.), 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.homelabA full tofu plan is clean. The Grafana Access app has an extra allow-mcp policy that admits the Grafana MCP client by Access service token; it is in Terraform, and its token client ID comes from grafana_mcp_service_token_id in terraform.tfvars (leave it empty for email OTP only). 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://home.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 |
- |
| site | Personal site, static pages and CV PDFs | https://domain.com (www. redirects) |
- |
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, personal site: the apex <domain> serves the personal site with the same design as the Tolon site: static files only, GET and HEAD only, a strict CSP with no third-party origins, nginx per-IP limits and the shared edge rate limit.
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).
- DNSSEC on the zone (the registrar is Cloudflare, so the DS record is published automatically)
- Minimum TLS 1.2, Always Use HTTPS, Always Online
- DMARC
p=reject: no mail is sent from the domain, Email Routing only receives - CAA: only the CAs that Cloudflare Universal SSL uses may issue certificates
- Cloudflare Free Managed Ruleset, plus 2 custom rules on the static hosts: block methods other than GET, HEAD and OPTIONS, and block common exploit probes (
/wp-,/.env,/.git,.php, ...) - Prometheus job
public-sitesprobes the public sites end to end through Cloudflare
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-siteandsiterun as the unprivileged nginx user on their own networks (tolon,site), each 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 16 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/
│ ├── site/ # Personal site at the apex
│ │ ├── nginx.conf # Headers, CSP, caching, rate limit
│ │ └── web/ # Built pages, deployed by rsync (gitignored)
│ └── 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
├── hardening.tf # DNSSEC, TLS, DMARC, CAA, WAF rules
├── email_routing.tf # Opt-in contact address forwarding
├── 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")
# 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
Tolon: no setup beyond docker compose up -d tolon-site. The page is static; see apps/tolon-site/README.md.
Personal site: copy the built pages into apps/site/web/ (for example rsync -a --delete <build>/ apps/site/web/), then docker compose up -d site. If the inline JSON-LD changes, update its sha256 hashes in the CSP in apps/site/nginx.conf.
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.