Securing a Self-Hosted AI Agent: The Container Layer Nobody Covers
Agent security advice comes in two layers. There’s the operating system — non-root user, key-only SSH, a firewall — and there’s the agent’s own configuration, where you deny shell execution and confine filesystem access.
Between those two sits the container, and almost nobody writes about it. That’s the gap this article fills, and rather than quote the flags from documentation we ran them and recorded what each one actually blocks.
If you haven’t done the other two layers, do those first: the OS hardening sequence is about twenty minutes — or the fuller version with a verify command for every step, which is where we found that grepping sshd_config can report nothing on a correctly hardened server. The agent config baseline covers binding the gateway to loopback with token auth and denying the shell tools. Neither of those protects you from what’s below.
The defaults are worse than you’d guess
Every test below ran on Docker 29.6.1. Start with the baseline — what does a container run as if you don’t say?
$ docker run --rm alpine:3.20 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),
3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
Root, with supplementary groups including disk and wheel. That’s the default your agent runs as unless you intervene.
And it can write anywhere in its own filesystem:
$ docker run --rm alpine:3.20 sh -c 'echo test > /evil.txt && echo WROTE'
WROTE
An agent that can be talked into writing files can write them anywhere in the image — including over its own binaries and startup scripts. That’s persistence, and it survives whatever you told the agent not to do at the application layer.
The four flags, and what each one actually stops
1. Run as a non-root user
docker run --user 10001:10001 ...
uid=10001 gid=10001 groups=10001
Note what’s gone: not just root, but the entire supplementary group list. Pick a high UID that doesn’t exist on the host, so that if a bind mount is misconfigured the container’s identity doesn’t collide with a real account.
2. Make the root filesystem read-only
docker run --read-only --tmpfs /tmp ...
Verified, the write that succeeded above now fails:
sh: can't create /evil.txt: Read-only file system
And /tmp still works, which is what makes this practical:
$ docker run --rm --read-only --tmpfs /tmp alpine:3.20 \
sh -c 'echo ok > /tmp/scratch && cat /tmp/scratch'
ok
The --tmpfs /tmp part is the difference between this working and you giving up on it. Almost every application needs somewhere to write; give it a tmpfs that vanishes on restart rather than a writable image.
3. Drop the capabilities
Default versus dropped, straight from the kernel’s own accounting:
default: CapEff: 00000000a80425fb
--cap-drop=ALL: CapEff: 0000000000000000
What that means concretely — the default container can change file ownership, and the hardened one can’t:
default: CHOWN SUCCEEDED
--cap-drop=ALL: chown: /f: Operation not permitted
An agent doing text generation and HTTP calls needs none of these. Drop everything and add back only what breaks.
4. Block privilege escalation
docker run --security-opt=no-new-privileges ...
default: NoNewPrivs: 0
with the flag: NoNewPrivs: 1
Off by default. It costs nothing and it closes the setuid-binary route out of your non-root user.
The mount that quietly gives away your files
This is the one that matters most for agents specifically, because the entire point of a personal agent is giving it access to your things.
A bind mount is read-write unless you say otherwise. We gave a container a host directory and had it append:
$ echo "host secret" > /tmp/agentdata/notes.txt
$ docker run --rm -v /tmp/agentdata:/data alpine:3.20 \
sh -c 'echo "agent wrote this" >> /data/notes.txt'
$ cat /tmp/agentdata/notes.txt
host secret
agent wrote this
The container modified a file on the host. Add :ro and it can’t:
$ docker run --rm -v /tmp/agentdata:/data:ro alpine:3.20 \
sh -c 'echo "agent wrote this" >> /data/notes.txt'
sh: can't create /data/notes.txt: Read-only file system
Two rules follow, and they’re the whole of mount hygiene:
-
Mount the narrowest directory that works, never a home directory and never
/. If the agent summarises invoices, it gets the invoices folder. -
Default to
:roand grant write access one path at a time. Most agent tasks are read-and-report; the ones that genuinely need to write are a minority and you should know which they are.
Secrets: the advice that doesn’t work
Standard guidance is to keep API keys out of your code and put them in an environment file with restrictive permissions. We tested that and it does not do what people think.
Passing a key directly is obviously visible:
$ docker run -d -e OPENAI_API_KEY=sk-SUPERSECRET123 alpine:3.20 sleep 30
$ docker inspect <id> --format '{{range .Config.Env}}{{println .}}{{end}}'
OPENAI_API_KEY=sk-SUPERSECRET123
Now the same thing via a 600-permission env file, which is the advice everyone gives:
$ ls -l /tmp/agent.env
-rw------- 1 axel axel 30 Aug 17 15:32 /tmp/agent.env
$ docker run -d --env-file /tmp/agent.env alpine:3.20 sleep 30
$ docker inspect <id> --format '{{range .Config.Env}}{{println .}}{{end}}'
OPENAI_API_KEY=sk-FROMFILE456
Identical result. The file permissions protected the file and did nothing for the container. Docker copies the values into the container’s configuration, and docker inspect prints them in plain text to anyone who can reach the Docker socket — which on our box means every member of the docker group.
Mounting the secret as a read-only file instead keeps it out of that configuration entirely:
$ docker run -d -v /tmp/agent.env:/run/secrets/agent.env:ro alpine:3.20 sleep 30
$ docker inspect <id> --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i openai
(nothing)
$ docker exec <id> cat /run/secrets/agent.env
OPENAI_API_KEY=sk-FROMFILE456
The application can still read it; docker inspect can’t. If your agent only accepts configuration through environment variables, have its entrypoint read the mounted file and export the value at startup — the secret then exists only in the process, not in the container’s stored config.
And remember what Docker group membership means. Anyone in it can inspect any container, mount any host path and run privileged containers. It is root equivalence. Treat adding a user to the docker group as granting administrator access, because it is.
Putting it together
docker run -d --name agent \
--user 10001:10001 \
--read-only --tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges \
-v /srv/agent/config:/config:ro \
-v /srv/agent/secrets/agent.env:/run/secrets/agent.env:ro \
-v /srv/agent/work:/work \
-p 127.0.0.1:8080:8080 \
your-agent-image
Reading the important parts: unprivileged user, immutable filesystem with a disposable scratch space, no capabilities, no escalation route, config and secrets read-only, exactly one writable directory, and the port bound to 127.0.0.1 rather than every interface.
That last item is the one people get wrong, and note that binding to localhost is doing real work here because Docker publishes past ufw — container traffic is routed before ufw’s chains, so a firewall rule alone won’t save you. Reach the interface over Tailscale rather than opening it.
The pre-flight audit
Before you point an agent at your email or your files, run through this. Anything unchecked is a decision you’re making by default rather than on purpose.
Network
- Is the agent’s port bound to
127.0.0.1or a private interface, not0.0.0.0? - Have you checked from outside the box rather than trusting the config? The audit takes ten minutes
- Is the web interface reachable only over a private network like Tailscale or WireGuard?
- Does the gateway require authentication even on localhost?
Container
5. Non-root user with a high UID?
6. --read-only root filesystem with a tmpfs for scratch?
7. --cap-drop=ALL, adding back only what demonstrably breaks?
8. --security-opt=no-new-privileges?
Data
9. Is every bind mount the narrowest path that works, and :ro unless it must be writable?
10. Are secrets mounted as files rather than passed as environment variables?
11. Is every API key scoped to the minimum permission it needs — and for anything financial, is withdrawal disabled?
Operations 12. Is the agent version current, and do you have a way of finding out when it isn’t? The exposure incidents in this category have been patched vulnerabilities, not novel ones.
If you check one thing, check number 1. At the disclosure of CVE-2026-25253 — a one-click remote code execution flaw rated CVSS 8.8 — over 40,000 instances were found publicly exposed and roughly 63% were assessed as vulnerable. Every one was configured by someone who intended to secure it later.
Where to run it
Hostinger — the practical pick, with one-click Docker templates that get you to a working container without a broken install first. 4GB at $6.49 promotional and $11.99 at renewal, 8GB at $8.79 and $14.99 — and buy the 8GB tier if browser automation is anywhere in your plan, because a headless Chromium instance wants 1–2GB by itself.
Hetzner — the best hardware per euro and a German company if jurisdiction matters to you. €19.99 for 4GB on the CPX line after the June 2026 repricing, plus €0.50/month for an IPv4 address; check availability on the cheaper tier before planning around it.
DigitalOcean — the documentation choice at $24 for 4GB, roughly double Hostinger’s renewal rate. Worth it if you value predictable APIs and thorough guides over price.
Cloudzy — bills month-to-month with no term at $17.37 for 4GB, which suits a project you might abandon. Nothing stranded if you stop.
UltaHost — more cores per plan than most at this price, and unusually its two-year discounted rate is also its renewal rate, so there’s no year-three cliff.
For sizing rather than securing, what a self-hosted agent actually needs covers the RAM floors, and the alternatives comparison found they all want roughly the same 4GB.
How we checked this
Every container behaviour in this article we ran ourselves, on Docker 29.6.1 with AppArmor, the builtin seccomp profile and cgroup namespaces enabled, using alpine:3.20, on 18 August 2026. The id output, the CapEff values, the read-only filesystem errors, the NoNewPrivs values, the bind-mount write to the host file, and the docker inspect secret disclosures are all copied from real terminal output rather than reconstructed from documentation. Anyone can reproduce them in a few minutes.
The secrets finding is the one we’d most encourage you to verify yourself, because it contradicts common advice — including advice we have given elsewhere in this series and are now qualifying: an env file with 600 permissions does not keep the value out of docker inspect. We tested -e and --env-file and both appeared in .Config.Env; a read-only bind mount of the same file did not.
Its limits. We tested that the secret is absent from .Config.Env, not that it is unreachable by every other route — a mounted file is still readable by anyone who can docker exec into the container or read the host path, which again means the Docker group. Mounting secrets as files raises the bar; it does not make them private from a host-level attacker. A dedicated secret store with short-lived credentials is the stronger answer and we have not tested one here.
What we did not do: run any actual AI agent, test any agent’s own configuration surface, verify that a specific agent tolerates a read-only root filesystem or --cap-drop=ALL (some will need capabilities added back, which is why the article says add back what breaks), or attempt to escape a hardened container. This is defence-in-depth advice, not a proof of containment — a container is an isolation boundary, not a security boundary in the way a separate machine is.
The exposure figures — over 40,000 instances publicly reachable with roughly 63% assessed vulnerable at the CVE-2026-25253 disclosure, rated CVSS 8.8 — are from security reporting of the February 2026 disclosure, cited consistently across our coverage. The claim that Docker’s traffic bypasses ufw is from Docker’s own documentation, quoted in our VPS security guide. We have not independently reproduced the ufw bypass.
Provider pricing is each host’s published rates read across this series in August 2026 rather than re-verified today.
The host links above are affiliate links. Every hardening measure in this article is free, and the honest summary is that the flags matter far less than item 1 of the audit — which costs nothing and is where the 40,000 exposed instances went wrong.
FAQ
Should I run my AI agent in a container?
Yes, and with the flags rather than the defaults. A default container runs as root with a broad capability set and a writable filesystem, which removes most of the benefit you were hoping for.
Does chmod 600 on my .env file protect my API keys?
It protects the file. It does not stop Docker copying the values into the container configuration, where docker inspect prints them in plain text — we verified this. Mount the secret as a read-only file instead.
What Docker flags should I use for an AI agent?
--user with a high UID, --read-only with --tmpfs /tmp, --cap-drop=ALL, --security-opt=no-new-privileges, :ro on every bind mount that doesn’t need writing, and publish the port to 127.0.0.1 rather than all interfaces.
Is a read-only root filesystem practical for an agent?
Yes, provided you add --tmpfs /tmp. Applications almost always need scratch space; a tmpfs gives them somewhere that disappears on restart instead of a writable image they could be induced to modify.
Why does my container still reach the internet after I set up ufw?
Because Docker routes container traffic before ufw’s chains, which its own documentation acknowledges. Bind the published port to 127.0.0.1 or use the DOCKER-USER chain.
Is being in the docker group safe?
Treat it as root equivalence. A member can inspect every container’s configuration, mount any host path and run privileged containers, so it is administrator access under a different name.
What’s the single most important thing?
Not exposing the agent’s port to the internet. Over 40,000 instances of one popular agent were found publicly reachable at a disclosure, with roughly 63% vulnerable to a one-click remote code execution flaw.