Why Rootless Containers Still Need Guardrails
Running containers as a non‑root user is a solid first line of defense, but it’s not a silver bullet. A mis‑configured image, a vulnerable app, or a simple oversight can still let a container touch /etc, /var, or even your own home directory. This post walks through the hardening steps that keep file writes strictly inside a rootless runtime’s writable layer.
1. The Rootless Model in 2026
Rootless mode is all about user namespaces. The container’s root (uid 0) gets mapped to a non‑root UID on the host, and the kernel enforces that the process can only see files that belong to that UID. The catch is that the mapping is per‑process, not per‑filesystem. If a container mounts a host directory or a volume, the process inside can write to it unless the mount options explicitly deny it.
| Feature | What it does | What it doesn’t do |
|---|---|---|
--userns=keep-id |
Keeps the container UID/GID the same as the host user | Doesn’t stop writes to host mounts |
--rootless |
Runs the runtime as a regular user | Doesn’t sandbox the filesystem beyond the user namespace |
--tmpfs |
Mounts an in‑memory filesystem | Still visible to the container, but not to the host |
2. The Problem: Unintended Host Writes
Take a quick example: a web server that mounts /var/www from the host.
podman run --rm -d \
-v /var/www:/var/www \
-p 8080:80 \
nginx:latest
Even with rootless mode, the container can create /var/www/secret.txt because the bind mount is read‑write by default. In a multi‑tenant lab or when pulling untrusted images, that’s a real risk—overwriting config files, dropping binaries, leaking secrets.
3. The Hardening Strategy
I’m breaking the hardening into three layers:
- Runtime flags – enforce read‑only mounts by default.
- Filesystem permissions – set strict ownership and mode on host directories.
- Seccomp / AppArmor / SELinux – add a policy layer.
3.1 Runtime Flags
Rootless runtimes ship a --read-only flag that mounts the container’s root FS as read‑only. Any writable mounts must be explicitly declared.
podman run --rm -d \
--read-only \
-v /var/www:/var/www:ro \
-p 8080:80 \
nginx:latest
--read-onlyprotects the container’s own filesystem.:roon the bind mount stops the container from writing to/var/www.
If you need a writable volume, use a dedicated volume that the container owns:
podman volume create webdata
podman run --rm -d \
-v webdata:/var/www \
nginx:latest
The volume will be owned by the user that created it, and the container will write only to that volume.
3.2 Host Directory Permissions
Even with :ro, a container can still write if the host permissions allow it. The safest approach is to give the directory a non‑root owner and restrict group/other access.
# Create a dedicated user for the container
sudo useradd -r -M -s /usr/sbin/nologin webuser
# Assign ownership
sudo chown webuser:webuser /var/www
sudo chmod 750 /var/www
Now only webuser and the owner can write. If your container runs as UID 1000 (your normal user), it won’t be able to write unless you map that UID to webuser inside the container.
3.3 Seccomp / AppArmor / SELinux
A security profile gives an extra layer. With SELinux in enforcing mode, label the directory and enforce a policy that denies writes from untrusted contexts.
# Label the directory
sudo semanage fcontext -a -t httpd_sys_content_t "/var/www(/.*)?"
sudo restorecon -Rv /var/www
Then, in the container, set the SELinux context to httpd_sys_content_t and use the --security-opt flag:
podman run --rm -d \
--security-opt label=type:httpd_sys_content_t \
-v /var/www:/var/www:Z \
nginx:latest
The :Z option tells Podman to relabel the mount for the container, ensuring it can read but not write.
4. Practical Example: A Secure Web App
Let’s walk through a real‑world scenario: deploying a simple Node.js API that writes logs to /var/log/api.
4.1 Prepare the Host
sudo mkdir -p /var/log/api
sudo chown 1000:1000 /var/log/api
sudo chmod 750 /var/log/api
Assume your user UID is 1000.
4.2 Build the Image
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]
Build:
podman build -t myapi:latest .
4.3 Run with Strict Mounts
podman run --rm -d \
--read-only \
-v /var/log/api:/var/log/api:rw \
-p 3000:3000 \
myapi:latest
- The container’s root FS is read‑only.
/var/log/apiis the only writable mount, and it’s owned by UID 1000.
If the app tries to write elsewhere, it will fail with EACCES.
4.4 Verify
Inside the container:
podman exec -it $(podman ps -q -f ancestor=myapi:latest) sh
# Attempt to write to /tmp
echo test > /tmp/foo
# -> Permission denied
Outside:
ls -l /var/log/api
# -rw-r----- 1 1000 1000 0 Oct 24 12:00 app.log
The log file is created only in the designated directory.
5. Trade‑offs and Caveats
| Trade‑off | Explanation | Mitigation |
|---|---|---|
| Performance | Read‑only root FS can add I/O overhead for apps that expect to write to /tmp. |
Use tmpfs mounts: -v /tmp:/tmp:rw,tmpfs. |
| Complexity | Managing multiple volumes and SELinux labels can be tedious. | Automate with Ansible or systemd‑unit drop‑ins. |
| Compatibility | Some legacy images expect to write to /var/lib or /etc. |
Use --tmpfs /var/lib or --tmpfs /etc to satisfy them. |
| User Namespace Limits | The host UID range may not cover all container UIDs. | Map container UIDs to host UIDs with --userns=keep-id. |
6. Troubleshooting Checklist
-
Permission Denied Inside Container
- Verify mount options (
podman inspect <id>). - Check the container’s UID (
id). - Ensure the host directory is owned by that UID.
- Verify mount options (
-
Unexpected Host File Creation
- Inspect the container’s mount points.
- Look for bind mounts without
:ro. - Check for
--tmpfsthat might be shared with the host.
-
SELinux Denials
- Run
ausearch -m avc -ts recent. - Adjust file contexts with
semanage fcontext. - Use
--security-opt label=type:…to match the policy.
- Run
-
AppArmor Issues
- If using AppArmor, ensure the profile allows the required mounts.
- Use
aa-complaintemporarily to gather logs.
7. Automation Snippets
7.1 Systemd Unit for a Rootless Service
# /etc/systemd/system/api.service
[Unit]
Description=Secure Node API
After=network.target
[Service]
User=apiuser
Group=apiuser
ExecStart=/usr/bin/podman run --rm \
--read-only \
-v /var/log/api:/var/log/api:rw \
-p 3000:3000 \
myapi:latest
Restart=on-failure
PrivateTmp=true
ProtectSystem=full
ProtectHome=yes
ReadOnlyDirectories=/
ReadWriteDirectories=/var/log/api
Reload and start:
sudo systemctl daemon-reload
sudo systemctl enable --now api.service
7.2 Ansible Playbook Snippet
- name: Deploy secure API
hosts: web
become: true
tasks:
- name: Create log directory
file:
path: /var/log/api
owner: 1000
group: 1000
mode: '0750'
state: directory
- name: Build image
podman_image:
name: myapi:latest
path: /opt/api
- name: Run container
podman_container:
name: api
image: myapi:latest
state: started
restart_policy: unless-stopped
read_only: true
mounts:
- source: /var/log/api
target: /var/log/api
type: bind
read_only: false
ports:
- "3000:3000"
8. Looking Ahead
Rootless runtimes are now the default on Fedora, Debian, Arch, and more. Kernel support for user namespaces is mature, and tools like systemd-nspawn and runc play nicely together. The biggest remaining hurdle is the human factor: remembering to set :ro on every bind mount and keeping directory permissions tight. Automation, as shown above, is the practical way to enforce these hardening steps consistently.
See also
- Configuring systemd‑resolved to Use a Custom DNS Server Only on the VPN Interface
- Recovering from emergency mode after a bad /etc/fstab entry on Ubuntu 24.04: a single‑command root shell solution
- How to Give the Backup User Read‑Only Access to /var/log via ACLs Without Granting sudo
- Rootless Podman on a Raspberry Pi – Solving the “Cannot Bind Mount /home/user/data” Permission Issue
- Finding the Most Frequent Syslog Lines in /var/log/syslog with awk, sort, and head