6 min read @leo

Building a Home SOC on k3s: Falco, Trivy, Prometheus & Telegram Alerts — All in GitOps

Building a Home SOC on k3s: Falco, Trivy, Prometheus & Telegram Alerts — All in GitOps

This is the full build log of turning a single-node k3s homelab into a working, GitOps-managed Security Operations Center (SOC). Everything here is declarative — reconciled by Flux from Git — so the whole stack is reproducible and auditable. The design follows the four SOC functions: collect → detect → alert → isolate.

The stack

  • Telemetry: kube-prometheus-stack (Prometheus + Grafana), Loki + Grafana Alloy for logs
  • Detection: Falco (runtime/syscall) + Trivy Operator (image vulnerabilities, misconfigurations, exposed secrets)
  • Alerting: Alertmanager → Telegram
  • Isolation: Kubernetes NetworkPolicies (default-deny on the crown jewels)
  • Edge: Traefik behind a Cloudflare Tunnel, cert-manager for TLS
  • Secrets: Vault + External Secrets Operator (nothing sensitive in Git)

1. Alerting: Alertmanager → Telegram

Detection is worthless without a channel out. First stop: turn Alertmanager on and route everything to Telegram. The bot token comes from Vault via an ExternalSecret and is mounted into Alertmanager; the chat_id is not secret but must be an unquoted integer — a quoted string fails to unmarshal and the Alertmanager pod never starts.

# infrastructure/monitoring/helm-release.yaml (kube-prometheus-stack values)
alertmanager:
  enabled: true
  alertmanagerSpec:
    secrets:
      - alertmanager-telegram          # bot token mounted from this secret
    storage:
      volumeClaimTemplate:
        spec:
          storageClassName: local-path
          accessModes: ["ReadWriteOnce"]
          resources:
            requests:
              storage: 1Gi
  config:
    global:
      resolve_timeout: 5m
    route:
      receiver: telegram
      group_by: ["alertname", "namespace"]
      group_wait: 30s
      group_interval: 5m
      repeat_interval: 4h
      routes:
        - receiver: "null"             # silence the always-firing heartbeat
          matchers: ['alertname = "Watchdog"']
    receivers:
      - name: "null"
      - name: telegram
        telegram_configs:
          - bot_token_file: /etc/alertmanager/secrets/alertmanager-telegram/bot-token
            chat_id: -1001234567890      # integer, NOT quoted
            parse_mode: HTML
            send_resolved: true
            message: |-
              {{ if eq .Status "firing" }}🔴{{ else }}✅{{ end }} {{ .Status | toUpper }} ({{ .Alerts | len }})
              {{ range .Alerts }}
              {{ .Labels.alertname }} [{{ .Labels.severity }}]
              ns: {{ .Labels.namespace }} · {{ .Labels.pod }}{{ .Labels.instance }}
              {{ .Annotations.summary }}{{ .Annotations.description }}
              {{ end }}

The token itself is pulled from Vault:

# ExternalSecret — bot token lands in the "alertmanager-telegram" secret
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
  name: alertmanager-telegram
  namespace: monitoring
spec:
  refreshInterval: 1h
  secretStoreRef:
    kind: ClusterSecretStore
    name: vault
  target:
    name: alertmanager-telegram
    creationPolicy: Owner
  data:
    - secretKey: bot-token
      remoteRef:
        key: secret/infrastructure/alertmanager
        property: bot-token

2. Detection rules (PrometheusRule)

kube-prometheus-stack ships a solid set of default Kubernetes alerts; on top of those, a custom PrometheusRule covers the security-specific signals — Trivy findings, certificate expiry, Traefik error/brute-force spikes, and Falco runtime events.

# infrastructure/monitoring/alert-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: soc-custom-rules
  namespace: monitoring
  labels:
    release: kube-prometheus-stack     # so Prometheus selects it
spec:
  groups:
    - name: trivy.rules
      rules:
        - alert: TrivyCriticalVulnerabilities
          expr: sum(trivy_image_vulnerabilities{severity="Critical"}) > 0
          for: 15m
          labels: { severity: warning }
          annotations:
            summary: "Critical CVEs present in running images"
        - alert: TrivyExposedSecrets
          expr: sum(trivy_image_exposedsecrets{severity=~"Critical|High"}) > 0
          for: 15m
          labels: { severity: critical }
          annotations:
            summary: "Exposed secrets found in container images"
    - name: cert-manager.rules
      rules:
        - alert: CertManagerCertExpiringSoon
          expr: certmanager_certificate_expiration_timestamp_seconds - time() < 7 * 24 * 3600
          for: 1h
          labels: { severity: warning }
          annotations:
            summary: "TLS certificate expiring in <7d"
    - name: traefik.rules
      rules:
        - alert: TraefikHighAuth4xxRate
          expr: |
            sum by (entrypoint) (rate(traefik_entrypoint_requests_total{code=~"401|403"}[5m])) > 5
          for: 10m
          labels: { severity: warning }
          annotations:
            summary: "Auth failure spike — possible brute-force"
    - name: falco.rules
      rules:
        - alert: FalcoHighPriorityEvent
          expr: |
            sum by (rule, priority) (
              rate(falcosidekick_falco_events_total{priority=~"Critical|Error|Warning"}[5m])
            ) > 0
          for: 0m
          labels: { severity: critical }
          annotations:
            summary: "Falco: {{ $labels.rule }} ({{ $labels.priority }})"

3. Image scanning: Trivy Operator

Trivy Operator continuously scans every running image for vulnerabilities, misconfigurations and exposed secrets, and writes the results as CRDs. Two things matter for a smooth run: expose its metrics to Prometheus via a ServiceMonitor, and give the scan jobs enough memory to complete (the default limit is too small for the DB load + scan). Keeping scanJobsConcurrentLimit: 1 means one scan at a time.

# apps/trivy/helm-release.yaml (trivy-operator values)
operator:
  scanJobsConcurrentLimit: 1
  metricsVulnIdEnabled: true          # per-CVE metric for the drill-down table
  scannerReportTTL: "168h"            # rescan unchanged images weekly
trivy:
  resources:
    requests: { cpu: 100m, memory: 128Mi }
    limits:   { cpu: 500m, memory: 1Gi }   # enough to finish a scan cleanly
serviceMonitor:
  enabled: true                       # metrics -> Prometheus

Useful metrics this exposes: trivy_image_vulnerabilities{severity="Critical"}, trivy_image_exposedsecrets, trivy_resource_configaudits, and — with metricsVulnIdEnabledtrivy_vulnerability_id, which carries per-CVE labels (vuln_id, vuln_score, installed_version, fixed_version, image_repository). That last one powers the CVE drill-down in the dashboard.


4. Runtime detection: Falco

Falco is the "someone is inside a container right now" layer. It loads an eBPF probe and watches syscalls — shells spawned in pods, sensitive-file reads, unexpected outbound connections, privilege escalation. Events go to stdout (tailed into Loki by Alloy) and to falcosidekick, which fans out to Loki and exposes Prometheus metrics that feed the FalcoHighPriorityEvent alert.

# infrastructure/falco/helm-release.yaml
driver:
  kind: modern_ebpf          # CO-RE eBPF, no kernel headers needed
controller:
  kind: daemonset
falco:
  json_output: true
  json_include_output_property: true
  priority: notice
# Rule overrides — mute high-volume false positives so the signal stays clean
customRules:
  overrides.yaml: |-
    - rule: Contact K8S API Server From Container
      enabled: false          # fires on every scrape/sidecar/init — noise
    - rule: Drop and execute new binary in container
      enabled: false          # false-positive from CI runners / IaC tooling
falcoctl:
  artifact:
    install: { enabled: true }
    follow:  { enabled: true }
falcosidekick:
  enabled: true
  config:
    loki:
      hostport: http://loki.monitoring.svc.cluster.local:3100
      minimumpriority: notice
  serviceMonitor:
    enabled: true

Only Warning/Error/Critical events page Telegram; Notice/Informational stay in Loki for searching. Tuning noisy rules (above) is what keeps the alerting trustworthy.


5. Turning the edge into a log source (Traefik)

Traefik's access logs are the record of who hit what. Switch them to JSON, keep the headers a SOC cares about, and let Alloy parse status/method into low-cardinality labels.

# infrastructure/traefik/helm-release.yaml
logs:
  general: { level: INFO, format: json }
  access:
    enabled: true
    format: json
    fields:
      defaultMode: keep
      headers:
        defaultMode: drop
        names:
          User-Agent: keep
          X-Forwarded-For: keep
          Cf-Connecting-Ip: keep      # real client IP behind Cloudflare

Alloy tails all pod logs and adds a processing stage that extracts Traefik fields:

// infrastructure/monitoring/alloy.yaml (config snippet)
loki.process "enrich" {
  forward_to = [loki.write.default.receiver]
  stage.match {
    selector = "{namespace=\"kube-system\", pod=~\"traefik.*\"}"
    stage.json {
      expressions = { status = "DownstreamStatus", method = "RequestMethod" }
    }
    stage.labels { values = { status = "", method = "" } }
  }
}

Real client IPs behind Cloudflare

With a Cloudflare Tunnel, requests arrive from cloudflared and the true client IP lives in the Cf-Connecting-Ip header — not ClientHost. Because that header is kept in the access log, LogQL can group auth failures by the real external IP:

topk(15, sum by (request_Cf_Connecting_Ip) (
  count_over_time({namespace="kube-system", pod=~"traefik.*"}
    | json | DownstreamStatus=~"401|403" | request_Cf_Connecting_Ip != `` [$__range])
))

6. Isolation: NetworkPolicies on the crown jewels

A flat pod network means one compromised app can reach everything. The highest-value, lowest-risk first step is a default-deny ingress policy on the sensitive namespaces — the database and Vault — allow-listing only their real clients. Egress is left open so nothing breaks.

# infrastructure/security/netpol/vault.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: default-deny-ingress, namespace: vault }
spec:
  podSelector: {}
  policyTypes: [Ingress]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: allow-vault-clients, namespace: vault }
spec:
  podSelector: {}
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchExpressions:
              - key: kubernetes.io/metadata.name
                operator: In
                values: [external-secrets, monitoring, vault, kube-system]
      ports:
        - { protocol: TCP, port: 8200 }

Note kube-system in the allow-list — that is where Traefik lives, so the Vault UI stays reachable through the ingress. The same default-deny pattern applies per-app as a zero-trust rollout, one namespace at a time.


7. The SOC Overview dashboard

All of the above lands in a single Grafana dashboard — one pane that answers "is anything wrong, and is someone doing something?"

SOC Overview Grafana dashboard
SOC Overview — posture stats, runtime and vulnerability panels.

How to read it, top to bottom:

  • Alerts & Posture — stat tiles: firing Critical/Warning alerts, Trivy Critical/High CVEs, exposed secrets, critical misconfigs, certs expiring <14d, and the Falco event rate. Green means calm; a red tile is your cue to look down the page.
  • Runtime (Falco) & Vulnerabilities (Trivy) — Falco events by priority over time, a "top Falco rules" table, and Trivy vulnerabilities trended by severity. Notice-level Falco events are background noise; a spike in Warning+ is the real signal.
  • Edge (Traefik) — 5xx rate per service (app health) and 401/403 rate per entrypoint (brute-force signal).
  • Threat detail — a Loki log panel filtered to Falco Warning+ events, and a table of top external source IPs by 401/403 (using Cf-Connecting-Ip).
  • Vulnerabilities (Trivy detail) — a "top vulnerable images" table for patch prioritisation, and a fully filterable CVE drill-down (CVE ID, score, image, package, installed → fixed version) where each CVE links out to NVD.

The dashboard is provisioned as a ConfigMap picked up by the Grafana sidecar — Loki-backed panels reference the datasource through a ${loki} template variable rather than a hard-coded UID, which keeps provisioning stable across restarts.


Result

The mental model that ties it together:

  • Stat tiles — is anything wrong right now?
  • Falco graph + logs — is someone doing something?
  • Trivy — how exposed am I?
  • Traefik — who's knocking?

Detection (Falco + Trivy) → alerting (Telegram) → a single dashboard → isolation (NetworkPolicies), all reconciled from Git. A real SOC pattern, sized for a homelab, and fully reproducible.