Restrict Container Permissions
Learn to restrict container permissions for Python workloads in Kubernetes. This lesson covers security contexts, read-only root filesystems, and running as non-root, with hands-on exercises for Python services.
Focus: restrict container permissions for python workloads
Picture this: you’ve just deployed a Python microservice to Kubernetes, everything works, and then your security team flags the container because it’s running as root with a writable filesystem and all capabilities enabled. It’s a common blind spot. Whether your Python app is a FastAPI service, a Celery worker, or a data-processing job, Kubernetes grants your container more permissions than it needs by default — and that’s exactly what attackers exploit. This lesson teaches you how to restrict container permissions for Python workloads so you can lock down your deployments without breaking your app.
The problem this lesson solves
Most Python containers run as the root user (UID 0) because that’s the default in many base images. Combined with a writable root filesystem and the default set of Linux capabilities, this means that if an attacker compromises your process, they gain near-total control of the container — and possibly the host. Even without a vulnerability, misbehaving Python code (like accidentally writing to the wrong path) can cause unexpected side effects.
The real pain comes when you try to “just run as non-root” and realize your Python app breaks because it needs to write to /tmp, bind to a privileged port, or access a file owned by root. Without a systematic approach, you end up either leaving your container insecure or fighting obscure errors. This lesson gives you a clear, step-by-step way to restrict permissions safely.
Core concept / mental model
Think of a container as a locked apartment. By default, the tenant (your Python process) has the master key (root), can redecorate (writable filesystem), and has access to every door (capabilities). Restricting permissions means giving your tenant only the keys they need: a regular key (non-root user), a fixed set of rooms (read-only filesystem with a writable temp directory), and access to specific doors (dropped capabilities).
In Kubernetes, you control this through security contexts, which apply at the pod or container level. At the pod level, you can set security settings that apply to all containers; at the container level, you can override for each container. The main controls you’ll use:
runAsUserandrunAsNonRoot— predefine the Linux user ID (UID) and ensure the container doesn’t run as root.readOnlyRootFilesystem— makes the container’s root filesystem read-only.capabilities— drop all capabilities and add only the ones your app needs (e.g.,NET_BIND_SERVICEto bind to port 80).runAsGroup,fsGroup, andallowPrivilegeEscalation— control group ownership and privilege escalation.
A mental diagram:
Pod (securityContext)
├─ runAsUser: 1000
├─ runAsNonRoot: true
└─ Container (securityContext)
├─ readOnlyRootFilesystem: true
└─ capabilities: drop: [ALL] add: [NET_BIND_SERVICE]
This layered approach ensures that even if one container is compromised, the blast radius is minimal.
How it works step by step
- Identify your base image — Check the user that your Python image runs as. For example, the official
python:slimruns as root by default. - Create a non-root user in your Dockerfile — Add a user like
appuserwith a specific UID (e.g., 1000) and set the user in the Dockerfile. - Set the filesystem as read-only — In your Kubernetes manifest, set
readOnlyRootFilesystem: trueso the container can’t write to its root filesystem. - Provide a writable volume for needed locations — If your Python app writes to
/tmpor other paths, mount anemptyDirvolume and set the environment variable (e.g.,TMPDIR) to point there. - Drop all capabilities — In the container security context, set
capabilities: drop: ["ALL"]and add back only necessary ones (often none for Python apps). - Disable privilege escalation — Set
allowPrivilegeEscalation: falseto prevent processes from gaining more privileges. - Run as non-root — Set
runAsUser: 1000,runAsNonRoot: true, and optionallyrunAsGroup: 1000.
Here’s a cause-and-effect flow: if you set readOnlyRootFilesystem: true but your Python app writes to /tmp without a mounted volume, the app will crash. If you drop all capabilities but your app tries to bind to a port below 1024 (as non-root), it will fail — you’ll need to add NET_BIND_SERVICE back or use a higher port.
Hands-on walkthrough
Let’s apply these steps to a simple FastAPI app.
Step 1: Dockerfile — Start with a secure base image and add a non-root user.
FROM python:3.11-slim
# Create a non-root user with a specific UID
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Switch to the non-root user
USER appuser
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Step 2: Kubernetes Deployment — Apply the security context.
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: fastapi-secure
spec:
replicas: 2
selector:
matchLabels:
app: fastapi-secure
template:
metadata:
labels:
app: fastapi-secure
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: app
image: your-registry/fastapi-secure:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
runAsUser: 1000
runAsNonRoot: true
ports:
- containerPort: 8000
env:
- name: TMPDIR
value: "/tmp"
volumeMounts:
- name: tmp-volume
mountPath: /tmp
- name: cache-volume
mountPath: /cache
volumes:
- name: tmp-volume
emptyDir: {}
- name: cache-volume
emptyDir: {}
**Step 3: Apply the deployment**
```bash
kubectl apply -f deployment.yaml
kubectl get pods
Expected output (pods running with no security errors):
NAME READY STATUS RESTARTS AGE
fastapi-secure-7d8b9f9c6-abcde 1/1 Running 0 30s
fastapi-secure-7d8b9f9c6-fghij 1/1 Running 0 30s
If your app uses a volume for persistent data, mount it with emptyDir or a PVC and ensure correct permissions using fsGroup.
Compare options / when to choose what
There are several ways to enforce restricted permissions. Here’s a comparison:
| Approach | Best for | Pros | Cons |
|---|---|---|---|
| Inline securityContext | Quick hardening of a single workload | Simple, immediate, no extra tools | Hard to enforce consistently |
| Pod Security Admission (PSA) | Enforcing a baseline across namespaces | Built-in, namespaced policies | Limited to three modes (privileged/baseline/restricted) |
| OPA Gatekeeper / Kyverno | Custom, complex policies | Highly customizable, validation | Adds operational complexity |
| Distroless images | Minimal attack surface | No shell, no package manager | Requires build changes, debugging harder |
For most Python services, starting with an inline security context plus a baseline PSA is the fastest secure path. If you have compliance requirements (like PCI-DSS), you’ll want a policy engine like Kyverno to enforce non-root and read-only across the cluster.
Troubleshooting & edge cases
Container crashes with “Operation not permitted” — This often happens when readOnlyRootFilesystem: true but the app writes to its root filesystem. Fix: mount an emptyDir at the required path (e.g., /tmp) and set TMPDIR accordingly. Also check if your app writes to /usr or /var — move those writes to mounted volumes.
“Permission denied” when binding to a port below 1024 — As a non-root user, you can’t bind to ports <1024 unless you add the NET_BIND_SERVICE capability. Solution: either use a higher port (like 8000) or add the capability:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]
Your app ignores environment variables for temp directories — Python’s tempfile module checks TMPDIR first, but some libraries use their own settings. Instead of relying on env vars, you can set the TMPDIR in your container entrypoint or use a read-write volume at the exact path the library uses. For example, in your Dockerfile:
ENV TMPDIR=/tmp
And mount an emptyDir at /tmp.
Pod stuck in CreateContainerConfigError — This usually means your security context references an invalid UID or the image doesn’t have that user. Verify with kubectl describe pod and check securityContext — the UID must exist in the image or be resolvable.
What you learned & what's next
This lesson showed you how to restrict container permissions for Python workloads. You learned to:
- Explain the security risks of running Python containers as root with writable filesystems.
- Apply security contexts to run as non-root, set read-only root filesystems, and drop all capabilities.
- Complete a hands-on exercise deploying a secure FastAPI service.
- Compare inline security contexts with cluster-level policies like Pod Security Admission and OPA Gatekeeper.
The next logical step is to enforce these restrictions across an entire namespace using Pod Security Admission (PSA). PSA allows you to define a standard (like restricted) so that every new workload in that namespace automatically gets validated. This takes you from securing one deployment to securing your whole cluster with minimal effort.
Now that you can lock down a single container, it’s time to scale that security to the platform level.
Practice recap
Now try hardening your own Python deployment: create a simple Flask or FastAPI app, add a non-root user in the Dockerfile, and apply a security context with readOnlyRootFilesystem: true and dropped capabilities. Deploy it to your cluster and verify it runs. Then intentionally break it by mounting the root filesystem as writable without a volume — observe the error so you remember why this matters.
Common mistakes
- Forgetting to set
runAsNonRoot: true— the image may set a non-root user, but without this flag Kubernetes won't enforce it, leaving a root fallback if the image changes. - Setting
readOnlyRootFilesystem: truebut not mounting a writable volume for/tmp— Python's tempfile module and many libraries (e.g., pip) will fail with 'Read-only file system'. - Dropping all capabilities without adding
NET_BIND_SERVICEback — non-root users cannot bind to ports below 1024, causing a 'Permission denied' error on startup.
Variations
- Use a distroless base image (e.g.,
gcr.io/distroless/python3-debian11) that doesn't include a shell or package manager, further reducing attack surface. - Enforce restrictions at the namespace level using Pod Security Admission (PSA) with the built-in
restrictedpolicy instead of per-pod security contexts. - Use an admission controller like Kyverno to inject security contexts automatically for all new deployments, ensuring consistency without editing every manifest.
Real-world use cases
- A production FastAPI service that must comply with PCI-DSS — enforcing non-root and read-only filesystem to reduce breach impact.
- A Celery worker in a microservices cluster that processes untrusted data — dropping capabilities prevents privilege escalation from a compromised worker.
- A batch data-processing job (e.g., Pandas) that writes to a mounted PVC — using
fsGroupto ensure the non-root user can read/write the volume.
Key takeaways
- Security contexts let you control UID, filesystem writes, and Linux capabilities per pod or container.
- Always run as a non-root user with a dedicated UID (e.g., 1000) to minimize the blast radius if the container is compromised.
- Set
readOnlyRootFilesystem: trueand mountemptyDirvolumes at any writable paths (like/tmp) to keep your app functional. - Drop all capabilities by default and add back only what your Python app needs (e.g.,
NET_BIND_SERVICEfor low ports). - Disable privilege escalation with
allowPrivilegeEscalation: falseto blocksudo-like escapes. - For cluster-wide enforcement, combine inline security contexts with Pod Security Admission or a policy engine like Kyverno.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.