Skip to content
danielpsfPublic

About

This is the IaC of my homelab setup

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Homelab

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.

Architecture

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
Loading

Network Topology

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
Loading

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.

Wazuh Alert Pipeline

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
Loading

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

Terraform Infrastructure

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
Loading

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.

Running OpenTofu

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.homelab

A 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.

Services

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) -

Security

Double-Layer Authentication

Every public service is protected by two layers:

  1. Cloudflare Zero Trust — email OTP gate before any traffic reaches the server
  2. 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).

Edge Hardening (terraform/hardening.tf)

  • 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-sites probes the public sites end to end through Cloudflare

Container Hardening

All containers follow a hardened baseline:

  • security_opt: [no-new-privileges:true]
  • cap_drop: [ALL] — only add back capabilities that are strictly required
  • read_only: true with tmpfs mounts where possible
  • No containers expose ports to the host except Wazuh Manager (agent enrollment)
  • tolon-site and site run as the unprivileged nginx user on their own networks (tolon, site), each shared only with cloudflared

Secrets Management

  • Docker: All secrets live in .env (gitignored). Config files use ${VAR} substitution.
  • Terraform: Sensitive values in terraform.tfvars (gitignored). API token marked sensitive = 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).

Encrypted Files (git-crypt)

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 .gitattributes are 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-key

To 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.

AI-Powered Security Analysis

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:

  1. Proactive Attack Surface Audit — scans network exposure, container hardening, authentication posture, firewall state, and host patch level before even reading the alert digest
  2. 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
  3. 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)
  4. 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
  5. Synthesis & Reporting — assembles a structured security assessment with executive summary, threat matrix, detailed findings, prioritized remediation plan, Wazuh tuning recommendations, and active response proposals
  6. 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.

Project Structure

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

Getting Started

Prerequisites

  • 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

1. Clone and configure

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 credentials

2. Generate Wazuh certificates

Follow the Wazuh Docker deployment guide to generate TLS certificates, then place them in files/wazuh/certs/.

3. Create required volumes and secrets

# 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

4. Deploy infrastructure

# 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 ..

5. Start services

docker compose up -d

6. Set up Gotify (optional)

  1. Open Gotify at https://gotify.<your-domain> and log in with the admin credentials from .env
  2. Create an Application (e.g., "n8n Wazuh Alerts") — copy the app token for the n8n workflow
  3. Create a Client (e.g., "Homepage Widget") — copy the client token to HOMEPAGE_VAR_GOTIFY_KEY in .env
  4. Install the Gotify Android app and log in with the server URL and admin credentials

7. Restore the n8n workflow (optional)

  1. Open n8n at https://n8n.<your-domain>
  2. Create SMTP and Google Gemini credentials
  3. Create a Data Table named wazuh_alerts with 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)
  4. Import files/n8n/workflows/wazuh-ai-triage.json
  5. Update placeholder values: YOUR_DATA_TABLE_ID, YOUR_GEMINI_CREDENTIAL_ID, YOUR_SMTP_CREDENTIAL_ID, YOUR_GOTIFY_APP_TOKEN, and the email addresses
  6. Activate the workflow

8. Static sites

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.

9. Install Wazuh agent (optional)

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:1514

Grafana Dashboards

Four 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

Updating Wazuh Password Hashes

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.

License

This project is provided as-is for educational and personal use.

About

This is the IaC of my homelab setup

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages