How to Stop Docker‑Compatible Containers from Writing Sensitive Files to the Host with Rootless Mode

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:

  1. Runtime flags – enforce read‑only mounts by default.
  2. Filesystem permissions – set strict ownership and mode on host directories.
  3. 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-only protects the container’s own filesystem.
  • :ro on 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/api is 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

  1. 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.
  2. Unexpected Host File Creation

    • Inspect the container’s mount points.
    • Look for bind mounts without :ro.
    • Check for --tmpfs that might be shared with the host.
  3. SELinux Denials

    • Run ausearch -m avc -ts recent.
    • Adjust file contexts with semanage fcontext.
    • Use --security-opt label=type:… to match the policy.
  4. AppArmor Issues

    • If using AppArmor, ensure the profile allows the required mounts.
    • Use aa-complain temporarily 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