You Can’t Secure What You Don’t See
Container security has two halves, and they hate each other.
One half lives at image build time, scanning for known vulnerabilities before anything touches your cluster. Trivy does this. It’s the bouncer at the door, checking IDs, finding the one guy with a fake credential.
The other half lives at runtime, watching what’s actually happening inside your cluster right now. Is a container spawning a reverse shell? Did nginx just try to write to /etc for some reason? Falco catches this. It’s the security guard on the floor, walking the beat, noticing when someone’s carrying a ladder in the wrong direction.
The interesting part is stitching them together in k3s. Not a managed platform with three teams staffing security. Actual k3s. The one on your home lab server, or a small VPS, or a trio of Raspberry Pis in a rack that your neighbors definitely don’t know about.
This isn’t about building Fort Knox. It’s about knowing when someone’s opened a door they shouldn’t have, and knowing it happened instead of discovering it three weeks later in your logs.
The Setup
Here’s what we’re building:
- Trivy Operator: scans images as they land in your cluster, flags CVEs
- Falco: watches syscalls, detects behavioral anomalies
- Alert routing: both feed into a webhook (Slack, Discord, whatever)
- Optional: Grafana: one dashboard, all the signals
You need:
- A k3s cluster on any supported Kubernetes version. Neither tool is fussy here.
- A kernel of 5.8 or newer on every node. This is the real gate. Falco’s modern eBPF driver needs BPF ring buffer support and an exposed BTF, which in practice means 5.8+. Two commands settle it:
uname -rfor the version, andls /sys/kernel/btf/vmlinuxto confirm the kernel actually exposes BTF. Distro kernels can be new enough and still ship withoutCONFIG_DEBUG_INFO_BTF, which is common on ARM boards. Ubuntu 24.04 on 6.8 has it. An old CentOS 7 VPS on 3.10 has neither, and you’ll be falling back to the kernel module there. - The
helmbinary. - Five minutes of patience (less if you’re skipping the Grafana bit).
Part 1: Trivy Operator (Image Scanning)
Trivy Operator is the easy half. It runs a Kubernetes controller that automatically scans images in your cluster and creates VulnerabilityReport custom resources. In practice, every time a pod spins up, Trivy runs a scan, stashes the results, and goes quiet again. If something gets marked critical, you react. Otherwise, life goes on.
Install Trivy Operator
helm repo add aqua https://aquasecurity.github.io/helm-charts/helm repo updatehelm upgrade --install trivy-operator aqua/trivy-operator \ --namespace trivy-system \ --create-namespace \ --set serviceAccount.create=trueLeave the Trivy image alone and let the chart pull its own pinned default. That way you get whatever version ships with the operator release instead of pinning a stale tag that goes out of date the moment you publish it.
That’s it. Trivy Operator is now watching your cluster. Every pod that gets created gets scanned. The scan takes 30 seconds to a few minutes (depends on image size and how fast your network is), and results show up as a VulnerabilityReport object in the same namespace as the pod.
Check it:
kubectl get vulnerabilityreport -Akubectl describe vulnerabilityreport <name> -n <namespace>You’ll see something like:
Vulnerabilities: - Fixed Version: 8.4.0 Installed Version: 8.2.1 Resource: curl Severity: CRITICAL Title: curl: heap based buffer overflow in the SOCKS5 proxy handshake Vulnerability ID: CVE-2023-38545Trivy only scans artifacts you actually run, not your entire registry. You run nginx:latest with a known CVE in OpenSSL? It tells you. You run your own custom Python app with a pip dependency that’s three years old? It tells you that too.
Configure Trivy alerts
You’ll read blog posts telling you to build a CronJob that polls VulnerabilityReport objects with kubectl and jq and POSTs them somewhere. Don’t. Trivy Operator has shipped webhook broadcasting for a while, and it’s one Helm flag:
helm upgrade --install trivy-operator aqua/trivy-operator \ --namespace trivy-system \ --create-namespace \ --set operator.webhookBroadcastURL="http://alert-aggregator.security.svc.cluster.local/trivy" \ --set operator.webhookBroadcastTimeout=30sThat maps to the OPERATOR_WEBHOOK_BROADCAST_URL environment variable on the operator. Every report the operator produces gets POSTed to that URL as it’s written: vulnerabilityreport, exposedsecretreport, configauditreport, sbomreport, and the cluster-scoped variants. Two options worth knowing:
operator.webhookBroadcastCustomHeaders: comma-separatedkey:valuepairs, for when your receiver wants an auth token.operator.webhookSendDeletedReports=true: also fire when a report is deleted. Off by default, and you probably want it off.
The payload shape matters, because most examples get it wrong. Trivy Operator sends an envelope, not a bare report:
{ "verb": "created", "operatorObject": { "apiVersion": "aquasecurity.github.io/v1alpha1", "kind": "VulnerabilityReport", "metadata": { "name": "replicaset-nginx-6d4cf56db6-nginx", "namespace": "default" }, "report": { "artifact": { "repository": "library/nginx", "tag": "1.29" }, "summary": { "criticalCount": 1, "highCount": 4, "mediumCount": 11 }, "vulnerabilities": [] } }}So your receiver reads operatorObject.report.summary, not a top-level vulnerabilities key. If you’d rather not write a receiver at all, Aqua’s own Postee takes this webhook directly and fans it out to Slack, Teams, Jira, and friends.
Part 2: Falco (Runtime Detection)
Now we swap gears. Trivy told you what’s in your images. Falco tells you what your containers are doing. If a pod that’s supposed to run nginx suddenly starts trying to read /etc/shadow, Falco sees it. If something spawns a shell when it shouldn’t, Falco knows.
Falco taps the kernel for syscalls, either through a loadable kernel module or the modern eBPF probe embedded in the Falco binary. It matches those syscalls against rules in real time. It’s like a video camera that only records when something suspicious happens.
Install Falco
helm repo add falcosecurity https://falcosecurity.github.io/chartshelm repo updatehelm upgrade --install falco falcosecurity/falco \ --namespace falco \ --create-namespace \ --set driver.kind=modern_ebpf \ --set falco.json_output=truedriver.kind=modern_ebpf uses the eBPF probe baked into the Falco binary instead of a kernel module, so nothing gets compiled or downloaded on your nodes. It works on most k3s setups, VPS included, as long as the kernel clears the 5.8 bar from the requirements above. The chart’s default is auto, which picks modern eBPF when the kernel supports it and falls back to the kernel module when it doesn’t. Setting it explicitly means you find out at install time rather than discovering a kernel module got built on your Pi.
falco.json_output=true matters more than it looks. Falco’s default output is plain text, and every HTTP receiver in this article (and falcosidekick, and most third-party tooling) expects JSON. Turn it on now or spend an hour wondering why your webhook handler throws on request.json.
Don’t copy the gRPC flags from older tutorials. Falco 0.43 deprecated the legacy eBPF probe, the gVisor engine, and gRPC output. Falco 0.44 removed all three outright, and the chart’s current app version is 0.44.x. --set falco.grpc.enabled=true and --set falco.grpcOutput.enabled=true do nothing useful now. HTTP output is the supported path.
Verify:
kubectl logs -n falco -l app.kubernetes.io/name=falco --tail=50You’ll see Falco initializing, detecting syscalls, and any rule violations. By default, Falco has rules for:
- Unexpected shell spawns
- Unauthorized file writes
- Network activity from containers that shouldn’t network
- Privilege escalation attempts
- Suspicious process names (things that look like mimicry attacks)
The default ruleset is good, but you’ll probably want to tune it.
Configure Falco Rules
Falco loads its rules from /etc/falco/falco_rules.yaml inside the container. Don’t hand-roll a ConfigMap and try to mount it over the DaemonSet. The chart has a customRules map for exactly this, and it wires up the volume and the rules_files entry for you:
customRules: custom-rules.yaml: |- - rule: Unexpected shell in container desc: A shell spawned inside a container condition: > spawned_process and container and shell_procs output: > Shell detected (user=%user.name command=%proc.cmdline parent=%proc.pname container=%container.name image=%container.image.repository:%container.image.tag) priority: WARNING tags: [shell, container]
- rule: Sensitive file read in container desc: Reading sensitive files from a container condition: > open_read and container and fd.name in (/etc/shadow, /root/.ssh/id_rsa) output: > Sensitive file read attempt (user=%user.name file=%fd.name container=%container.name) priority: WARNING tags: [privilege, file]Apply it with helm upgrade falco falcosecurity/falco -n falco -f falco-custom-rules.yaml.
Three things in there will bite you if you copy a rule from a random gist:
shell_procs, notshell_spawned. There is noshell_spawnedmacro. Reference one that doesn’t exist and Falco refuses to load the whole rules file, which means Falco starts and detects nothing.open_read, notopen.openis a syscall name, not a macro. The macros areopen_readandopen_write, and both already filter down to real file descriptors.%container.name, not%container.info.name. No upstream rule usescontainer.info.name, because that field doesn’t exist.
Validate before you ship: falco -V /etc/falco/rules.d/custom-rules.yaml inside the pod tells you whether the file parses. The default ruleset is solid for most home labs, so customize only when you’re chasing a false positive or a workload nobody upstream models.
Part 3: Alert Routing (The Glue)
Both Trivy and Falco produce alerts, and by default they go to two different places. One lands in the operator’s webhook, the other in a pod log nobody reads. Put them in one place.
Falco to Webhook
Falco posts alerts to an HTTP endpoint through its http_output channel. The chart passes everything under falco: straight into falco.yaml, so the keys are snake_case, exactly as they appear in the config file:
helm upgrade falco falcosecurity/falco \ --namespace falco \ --set falco.json_output=true \ --set falco.http_output.enabled=true \ --set falco.http_output.url="http://alert-aggregator.security.svc.cluster.local/falco"Camel-case variants like falco.httpOutput.enabled are not chart keys. Helm accepts them without complaint, writes them into falco.yaml, and Falco ignores them. You get a healthy pod and zero webhooks, which is the worst failure mode a security tool has.
Keep json_output=true in there. Without it Falco POSTs a plain text line instead of a JSON body, and your receiver has to regex it. With it you get:
{ "hostname": "k3s-node-01", "output": "16:45:12.123456789: Warning Shell detected (user=root command=/bin/sh parent=containerd-shim container=nginx image=nginx)", "output_fields": { "container.image.repository": "nginx", "container.name": "nginx", "evt.time": 1788280512123456789, "proc.cmdline": "sh", "user.name": "root" }, "priority": "Warning", "rule": "Unexpected shell in container", "source": "syscall", "tags": ["container", "shell"], "time": "2026-09-04T16:45:12.123456789Z"}Parse output_fields, not output. The output string is a human-readable render of your rule’s template and it changes the moment you edit the rule. output_fields is a stable map keyed by the field names Falco actually resolved.
Simple Alert Aggregator
You need a tiny webhook receiver. Here’s a Python one:
from flask import Flask, requestimport requestsimport os
app = Flask(__name__)
SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK", "")
@app.route("/falco", methods=["POST"])def falco_alert(): data = request.json priority = data.get("priority", "Unknown") rule = data.get("rule", "Unknown") fields = data.get("output_fields", {}) tags = ", ".join(data.get("tags", []))
emoji = {"Critical": "🔴", "Error": "🟠", "Warning": "🟡"}.get(priority, "⚪")
message = ( f"{emoji} *Falco: {rule}* ({priority})\n" f"host={data.get('hostname', '?')} " f"container={fields.get('container.name', '?')} " f"image={fields.get('container.image.repository', '?')}\n" f"user={fields.get('user.name', '?')} " f"cmd={fields.get('proc.cmdline', '?')}\n" f"tags: {tags}" )
if SLACK_WEBHOOK: requests.post(SLACK_WEBHOOK, json={"text": message})
return {"status": "ok"}, 200
@app.route("/trivy", methods=["POST"])def trivy_alert(): msg = request.json if msg.get("verb") == "deleted": return {"status": "ignored"}, 200
obj = msg.get("operatorObject", {}) if obj.get("kind") != "VulnerabilityReport": return {"status": "ignored"}, 200
meta = obj.get("metadata", {}) report = obj.get("report", {}) summary = report.get("summary", {}) critical = summary.get("criticalCount", 0) high = summary.get("highCount", 0)
if critical or high: artifact = report.get("artifact", {}) image = f"{artifact.get('repository', 'unknown')}:{artifact.get('tag', 'unknown')}" message = ( f"🚨 *Trivy: {image}*\n" f"critical={critical} high={high}\n" f"namespace={meta.get('namespace', '?')} report={meta.get('name', '?')}" ) if SLACK_WEBHOOK: requests.post(SLACK_WEBHOOK, json={"text": message})
return {"status": "ok"}, 200
if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)Deploy it as a service in your cluster:
apiVersion: apps/v1kind: Deploymentmetadata: name: alert-aggregator namespace: securityspec: replicas: 1 selector: matchLabels: app: alert-aggregator template: metadata: labels: app: alert-aggregator spec: containers: - name: aggregator image: python:3.14-slim command: - sh - -c - | pip install flask requests && python /app/app.py env: - name: SLACK_WEBHOOK valueFrom: secretKeyRef: name: slack-webhook key: url volumeMounts: - name: app mountPath: /app ports: - containerPort: 5000 volumes: - name: app configMap: name: alert-aggregator-code---apiVersion: v1kind: Servicemetadata: name: alert-aggregator namespace: securityspec: selector: app: alert-aggregator ports: - port: 80 targetPort: 5000---apiVersion: v1kind: ConfigMapmetadata: name: alert-aggregator-code namespace: securitydata: app.py: | # The Flask receiver from the previous code block goes here, verbatim. # kubectl create configmap alert-aggregator-code -n security --from-file=app.pyNow both Falco and Trivy point at http://alert-aggregator.security.svc.cluster.local/falco and /trivy. Alerts show up in Slack in real time.
Part 4: Optional Grafana Dashboard
If you’re already running Prometheus + Grafana (and who isn’t these days?), you can expose Falco and Trivy metrics there.
Falco exposes Prometheus metrics from its own webserver. metrics is a top-level block in the chart, not a key under falco:, so falco.metricsEnabled does nothing:
helm upgrade falco falcosecurity/falco \ --namespace falco \ --set metrics.enabled=true \ --set metrics.convertMemoryToMB=trueThat flips on falco.metrics.enabled and falco.webserver.prometheus_metrics_enabled together, and metrics land at /metrics on the webserver port, 8765, not the pod’s default port.
If you run the Prometheus Operator, skip the hand-written scrape config and let the chart create the ServiceMonitor:
helm upgrade falco falcosecurity/falco \ --namespace falco \ --set metrics.enabled=true \ --set serviceMonitor.create=trueDoing it by hand instead? The port matters, and this is the part most copy-paste configs get wrong:
- job_name: 'falco' kubernetes_sd_configs: - role: pod namespaces: names: - falco relabel_configs: - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] action: keep regex: falco - source_labels: [__address__] action: replace regex: '([^:]+)(?::\d+)?' replacement: '$1:8765' target_label: __address__Without that last relabel, Prometheus scrapes the pod IP on port 80 and every target sits red.
For Trivy, you can query the VulnerabilityReport CRD directly with a simple Kubernetes exporter, or use a sidecar that converts reports to Prometheus metrics.
The Grafana dashboard is nice-to-have, not must-have. Slack/Discord notifications are usually enough.
The Reality Check
Look, security isn’t a feature you ship. It’s infrastructure you live in. Falco + Trivy in k3s gives you:
- Image visibility: you know what’s in your containers before they run
- Behavioral visibility: you know what your containers are doing
- Alert fatigue: okay, yeah, this is the tradeoff. You’ll tune rules a lot. That’s normal.
The k3s version is lean, but be clear about what “lean” means. Falco is a privileged DaemonSet: one pod per node, reading syscalls through an eBPF probe. Trivy Operator is a single Deployment that spawns short-lived scan jobs. What you’re skipping is the SaaS control plane, the per-node license, and the “security team” org chart. You are still running an agent on every node, and it will show up in your node resource graphs. It scales down to three nodes on your home lab and up to a small production cluster without re-architecting anything.
Start with Trivy. Get image scanning working. Watch for 48 hours. Then add Falco. Let it baseline on your actual workloads (it’ll have false positives on day one, everything does). Then tune rules based on what you care about.
The goal is to catch things that matter, and know about them before 3 AM.
Quick Checklist
- Install Trivy Operator, verify
VulnerabilityReportobjects appear - Set
operator.webhookBroadcastURLand confirm a report POSTs to your receiver - Install Falco with eBPF, check logs for syscall events
- Point Falco at the same receiver with
falco.json_outputandfalco.http_output.* - Test: push a container with a known CVE, trigger a shell spawn rule
- Celebrate. You just implemented runtime security on k3s without an incident
Common Questions
Does Falco need a privileged container on every node?
Yes. Falco runs as a DaemonSet with elevated privileges because it reads kernel syscall events. The modern eBPF driver narrows this to specific capabilities rather than full privileged: true, which the chart handles for you. There is no unprivileged mode that still sees syscalls. Budget one pod per node.
Can Trivy Operator scan images in a private registry?
Yes. Trivy Operator reuses the imagePullSecrets already attached to the workload’s ServiceAccount or pod spec, so images your cluster can pull are images it can scan. For registries not covered that way, set operator.privateRegistryScanSecretsNames to map a namespace to a pull secret.
Which Falco driver should I pick on k3s?
Modern eBPF, if your kernel is 5.8 or newer and exposes BTF. Set driver.kind=modern_ebpf. It needs no compilation and no kernel headers. Leave driver.kind=auto and the chart probes for you, falling back to the kernel module on older kernels. The legacy eBPF probe was removed in Falco 0.44.
How much overhead does Falco add per node?
Expect a few hundred MB of RAM and low single-digit CPU percentage per node at rest on a home lab workload. Syscall-heavy pods push that up. Set resources.limits in the chart before you deploy, then watch actual usage for a week and tighten from there.
Do I still need this if I already scan images in CI?
Yes, for the runtime half. CI scanning tells you what CVEs an image shipped with on build day. It cannot tell you a container spawned a shell at 3 AM, read /etc/shadow, or started talking to a new IP. Trivy Operator also re-scans running images as new CVEs get published.