Security

Hardening Pods with Seccomp and AppArmor Profiles

Advanced45 min to complete11 min readApril 22, 2026Updated August 19, 2026

Quick answer

Restrict what system calls a container can make using seccomp, and what filesystem paths it can access using AppArmor. Two complementary Linux security mechanisms that drastically limit the blast radius of a compromised pod.

advanced · 45 min

Before you begin

  • kubectl access with cluster-admin
  • Linux nodes (seccomp is Linux-specific)
  • AppArmor-enabled kernel (most distributions ship with it)
  • Basic understanding of Linux system calls
Kubernetes
Security
Seccomp
AppArmor
Container Security

A compromised container that can make arbitrary system calls can escape to the host. Seccomp filters which system calls the container can make. AppArmor restricts filesystem paths, capabilities, and network access. Together they follow the principle of least privilege at the kernel level.

Most teams skip these because they feel complex. This tutorial starts with the safe, easy defaults and shows you when and how to write custom profiles.

Why This Matters

A container in Kubernetes shares the host kernel. Even with a non-root user inside the container, certain syscalls (ptrace, mount, unshare, setns) can allow privilege escalation or container escape.

Seccomp: "Only allow these syscalls." AppArmor: "Only allow access to these files, capabilities, and network operations."

Part 1: Seccomp

Step 1: Enable RuntimeDefault for All Pods

RuntimeDefault uses the container runtime's (containerd or crio) built-in seccomp profile, which blocks ~100 dangerous syscalls while allowing everything a typical application needs.

Apply it at the namespace level with a LimitRange or at the pod level:

yaml
1# At the pod level
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5  name: my-app
6spec:
7  template:
8    spec:
9      securityContext:
10        seccompProfile:
11          type: RuntimeDefault   # Use the runtime's default profile
12      containers:
13        - name: app
14          image: my-app:latest
15          securityContext:
16            allowPrivilegeEscalation: false
17            readOnlyRootFilesystem: true
18            runAsNonRoot: true
19            capabilities:
20              drop: ["ALL"]

This is the minimum you should apply to every workload. It blocks ptrace, reboot, kexec_load, open_by_handle_at, and about 100 others that a web service has no business calling.

Step 2: Enforce RuntimeDefault Cluster-Wide

Use a MutatingAdmissionWebhook or a pod security standard:

bash
# Apply the Restricted pod security standard to a namespace
# This BLOCKS pods that don't set seccompProfile — it does NOT inject it automatically
kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest

The restricted standard requires that pods explicitly set seccompProfile.type to RuntimeDefault or Localhost — pods that omit the field are rejected. This requirement was added in Kubernetes 1.25 and is the most commonly missed check when migrating from PodSecurityPolicy, which had no equivalent mandatory seccomp field. The standard also requires runAsNonRoot, no privilege escalation, and dropping all capabilities.

Step 3: Audit Mode — See What Syscalls Your App Makes

Before writing a custom profile, audit what syscalls your app actually needs:

bash
1# Run the container with Unconfined seccomp (logs all syscalls)
2kubectl patch deployment my-app --type=merge -p='{
3  "spec": {
4    "template": {
5      "spec": {
6        "securityContext": {
7          "seccompProfile": {"type": "Unconfined"}
8        }
9      }
10    }
11  }
12}'

Check the node's audit log:

bash
# On the node
journalctl -k | grep "type=SECCOMP" | head -50

Or use strace in a debug container:

bash
kubectl debug -it <pod-name> --image=ubuntu:22.04 -- strace -f -e trace=all ls /app

Step 4: Write a Custom Seccomp Profile

A custom profile allows only the syscalls your application actually uses:

json
1{
2  "defaultAction": "SCMP_ACT_ERRNO",
3  "architectures": ["SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_X32"],
4  "syscalls": [
5    {
6      "names": [
7        "accept4", "bind", "brk", "clock_gettime", "clone",
8        "close", "connect", "epoll_create1", "epoll_ctl", "epoll_wait",
9        "execve", "exit", "exit_group", "fcntl", "fstat",
10        "futex", "getdents64", "getpid", "gettid", "getuid",
11        "ioctl", "listen", "lseek", "mmap", "mprotect",
12        "munmap", "nanosleep", "openat", "read", "recvfrom",
13        "recvmsg", "rt_sigaction", "rt_sigprocmask", "rt_sigreturn", "select",
14        "sendmsg", "sendto", "set_robust_list", "set_tid_address", "setuid",
15        "sigaltstack", "socket", "stat", "uname", "wait4",
16        "write", "writev"
17      ],
18      "action": "SCMP_ACT_ALLOW"
19    }
20  ]
21}

This is a starting point for a Node.js HTTP server. defaultAction: SCMP_ACT_ERRNO means any syscall not in the allowlist returns EPERM.

Step 5: Load the Profile on Nodes

Custom seccomp profiles must be present on every node at a path known to kubelet. By default: /var/lib/kubelet/seccomp/.

For a managed cluster, use a DaemonSet to distribute profiles:

yaml
1apiVersion: apps/v1
2kind: DaemonSet
3metadata:
4  name: seccomp-profile-installer
5  namespace: kube-system
6spec:
7  selector:
8    matchLabels:
9      app: seccomp-profile-installer
10  template:
11    metadata:
12      labels:
13        app: seccomp-profile-installer
14    spec:
15      hostPID: true
16      initContainers:
17        - name: installer
18          image: busybox
19          command:
20            - sh
21            - -c
22            - |
23              mkdir -p /host/var/lib/kubelet/seccomp/profiles
24              cp /profiles/my-app.json /host/var/lib/kubelet/seccomp/profiles/
25          volumeMounts:
26            - name: host
27              mountPath: /host
28            - name: profiles
29              mountPath: /profiles
30      containers:
31        - name: pause
32          image: registry.k8s.io/pause:3.9
33      volumes:
34        - name: host
35          hostPath:
36            path: /
37        - name: profiles
38          configMap:
39            name: seccomp-profiles
40---
41apiVersion: v1
42kind: ConfigMap
43metadata:
44  name: seccomp-profiles
45  namespace: kube-system
46data:
47  my-app.json: |
48    {
49      "defaultAction": "SCMP_ACT_ERRNO",
50      "syscalls": [...]
51    }

Reference the profile in your pod:

yaml
securityContext:
  seccompProfile:
    type: Localhost
    localhostProfile: profiles/my-app.json

Or use the Security Profiles Operator (SPO) which manages this automatically:

bash
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/security-profiles-operator/main/deploy/operator.yaml

Part 2: AppArmor

AppArmor profiles restrict filesystem access, capabilities, and network operations at the process level.

Step 6: Check AppArmor Status on Nodes

bash
# On a node
cat /sys/module/apparmor/parameters/enabled
# Y = enabled

aa-status

Most cloud providers (GKE, AKS, EKS with Amazon Linux 2023) ship with AppArmor enabled.

Step 7: Use the Runtime's Default AppArmor Profile

Container runtimes load a default AppArmor profile (docker-default or cri-containerd.apparmor.d) that's already applied to all containers unless you override it.

Check that it's active:

bash
# On the node, find a running container
container_id=$(crictl ps --name my-app -q | head -1)
crictl inspect $container_id | grep -i apparmor

Step 8: Write a Custom AppArmor Profile

A custom profile for a Node.js HTTP server:

# /etc/apparmor.d/k8s-my-app
#include <tunables/global>

profile k8s-my-app flags=(attach_disconnected,mediate_deleted) {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  # Allow reading the application directory
  /app/** r,
  /app/node_modules/** r,

  # Allow writing to /tmp for temporary files
  /tmp/** rw,

  # Allow network access (TCP)
  network tcp,
  network udp,

  # Allow reading system info
  /proc/self/environ r,
  /proc/self/fd/ r,
  /proc/self/status r,

  # Deny write to /etc
  deny /etc/** w,

  # Deny access to /proc/kcore and dangerous procfs
  deny /proc/kcore r,
  deny /proc/sysrq-trigger rw,
  deny /proc/sys/kernel/core_pattern rw,

  # Allow executing node binary
  /usr/local/bin/node ix,
  /usr/bin/node ix,
}

Load the profile on nodes:

bash
# On each node
apparmor_parser -r -W /etc/apparmor.d/k8s-my-app
aa-status | grep k8s-my-app

Step 9: Apply the Profile to a Pod

In Kubernetes 1.30+, AppArmor is a first-class API field:

yaml
securityContext:
  appArmorProfile:
    type: Localhost
    localhostProfile: k8s-my-app

For older clusters, use the annotation:

yaml
metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-app

Step 10: Audit Mode

Test the profile in complain mode before enforce mode:

bash
# Load in complain mode
apparmor_parser -r -W -C /etc/apparmor.d/k8s-my-app

# Watch the audit log
journalctl -k -f | grep "apparmor"
# apparmor="ALLOWED" operation="open" profile="k8s-my-app" name="/sensitive/path"

Once you're confident the profile doesn't block legitimate operations, switch to enforce mode by reloading without -C.

Combining Both

The strongest posture combines both:

yaml
1spec:
2  template:
3    spec:
4      securityContext:
5        seccompProfile:
6          type: Localhost
7          localhostProfile: profiles/my-app.json
8        appArmorProfile:          # Kubernetes 1.30+ first-class field
9          type: Localhost
10          localhostProfile: k8s-my-app
11        runAsNonRoot: true
12        runAsUser: 1000
13      containers:
14        - name: app
15          securityContext:
16            allowPrivilegeEscalation: false
17            readOnlyRootFilesystem: true
18            capabilities:
19              drop: ["ALL"]

For clusters older than 1.30, use the annotation form instead of appArmorProfile:

yaml
metadata:
  annotations:
    container.apparmor.security.beta.kubernetes.io/app: localhost/k8s-my-app

Seccomp: filters syscalls the kernel receives. AppArmor: enforces MAC (mandatory access control) policy on file, network, and capability access. They complement each other — a bypass of one doesn't bypass the other.

Validation

bash
1# Verify seccomp is applied
2kubectl get pod my-app-xxx -o jsonpath='{.spec.securityContext.seccompProfile}'
3
4# Verify AppArmor annotation is present
5kubectl get pod my-app-xxx -o jsonpath='{.metadata.annotations}'
6
7# Check that dangerous syscalls are blocked
8kubectl exec my-app-xxx -- python3 -c "
9import ctypes, os
10CLONE_NEWNS = 0x20000
11# This should fail with EPERM under RuntimeDefault
12ret = ctypes.CDLL(None, use_errno=True).unshare(CLONE_NEWNS)
13print('exit code:', ret, 'errno:', ctypes.get_errno())
14"
15# Should print errno: 1 (EPERM) — unshare is blocked

Frequently Asked Questions

What is the difference between seccomp and AppArmor?

Seccomp filters system calls — it limits what the kernel interface a process may use. AppArmor restricts access to resources such as file paths, capabilities and network operations. They operate at different layers and compose well: seccomp reduces kernel attack surface, AppArmor constrains what the process can reach if it is compromised.

Is RuntimeDefault enough, or do I need a custom profile?

RuntimeDefault is a large improvement over unconfined and costs nothing to adopt — it blocks a substantial set of rarely used syscalls. A custom profile is worth writing for high-value workloads or when you need to block something specific. Start by moving everything to RuntimeDefault, which is where most of the benefit is.

How do I find out which syscalls my application needs?

Record them rather than guessing. Run the workload in audit mode and collect what it actually calls, then build the profile from that trace. Exercise realistic paths including startup, shutdown and error handling — a profile derived from the happy path blocks something the first time a failure occurs.

Why does my pod fail to start after adding an AppArmor profile?

Usually the profile is not loaded on the node the pod scheduled onto. AppArmor profiles are a node-level resource and must be present before a pod referencing them can run, so a cluster with mixed nodes fails intermittently depending on scheduling. Distribute profiles with a DaemonSet or bake them into the node image.

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.