The command was unremarkable. It lived in a wrapper script that had worked without incident for a year:
docker run --rm -e AWS_SECRET_ACCESS_KEY=wJalrXUt... my-tool
Everything about that looks careful. The secret isn't in the image. It isn't committed to a repository, and it never touches a disk. It gets handed to the container at runtime and vanishes when the container exits. It still ends up readable by every unprivileged user on the host, because arguments to docker run are arguments to a process, and process arguments sit in plain text inside /proc/<pid>/cmdline for as long as that container lives. Anyone with a shell can see it with ps.
Two qualifications, because the strong version of that sentence is not quite true everywhere. A host can mount /proc with hidepid, which stops unprivileged users reading other people's command lines — but it is a hardening option, not a default, and I have yet to meet a developer workstation or CI runner with it set. More to the point, hidepid does not help you at all against processes running as you, and on a workstation that is everything: your editor, your browser, every node module with a postinstall script. The people most exposed by this are not the other users on a shared box. They are the programs already running as you.
The incurious fix
Rotate the key. Which I did, same day, along with deleting the old one and an unrelated SES key that turned out to be sitting in the same drawer.
That is a real fix in the sense that the exposed credential stops working. It is also the kind of fix that teaches you nothing. The command that leaked it is still there, still correct-looking, still ready to leak the next one. Rotating a key after an exposure is housekeeping. It is not a change to the system that produced the exposure.
The actual fix is three characters shorter:
export AWS_SECRET_ACCESS_KEY; docker run --rm -e AWS_SECRET_ACCESS_KEY my-tool
Passing -e VAR with no value tells Docker to read the variable from the calling environment rather than from the command line. The container gets the same secret. The process list gets the variable's name and nothing else.
It is worth being precise about what that does and does not buy, because I had the scope of it wrong until I tested it. It cleans the process list. It does not clean the container's own record: docker inspect prints the resolved value in Config.Env for both forms, so the secret is still sitting in the daemon's state for the life of the container, readable by anyone in the docker group — which is to say, by anyone who is effectively root on that host already. The inherit trick is a real improvement over putting a secret on the command line. It is not the end of the problem.
Nor does it get the secret out of your shell. export puts it in the environment of every child process that shell goes on to spawn, which is a smaller audience than ps but not a small one. Both of these are solved by the same move, and it is the one the next section is really about: if the container fetches its own credential at startup, there is no value on the command line, none in the daemon's record, and none in your shell.
Which is the actual argument for containerizing your tools
Most writing on this subject sells containerized tooling on consistency: the same kubectl version for everyone, no more "works on my machine", no more polluting your laptop with six versions of Terraform. All true, and all slightly beside the point. You can solve version drift with asdf or a package manager and never touch a container.
The argument that actually pays is blast radius. A tool in a container can only reach what you hand it. That is a boundary you can reason about, and more importantly it is a boundary you can put a policy on. Mine look like this:
Credentials are fetched at runtime, never baked in. The entrypoint pulls what it needs from AWS Secrets Manager when the container starts. The image contains no secrets, so the image is not a liability if it is pulled, cached, or pushed somewhere careless. Of the fourteen tool environments I run, nine fetch credentials this way; the rest need none at all.
Scope is per-tool, not per-human. The environment that talks to Kubernetes gets a kubeconfig. The one that talks to Jira gets a Jira token. The one that talks to Jenkins gets Jenkins credentials. None of them has the others, because none of them was given the others. Compare that with the usual arrangement, where every credential you have ever configured sits in your home directory and any command you run inherits all of them.
The wrapper is the interface. Each environment is a run.sh that takes a command and runs it inside the container. That script is where the policy lives: which secrets are fetched, which paths are mounted, which network the container joins. It is version controlled and it is reviewable, which a shell history is not.
The part I did not plan for
I built these to stop fumbling credentials. What they turned out to be good for is something I could not have predicted when I started: they are what makes it safe to let an AI assistant operate real infrastructure.
The question people ask about AI-assisted operations is usually about trust, which is the wrong question, because it has no answer you can verify. The answerable question is about capability. An assistant that drives my Terraform environment can run Terraform. It cannot read my Jira token, because that token is not in that container. It cannot leave a long-lived key on the host, because the container fetched a short-lived one at startup and died with it. It cannot quietly do something on a production host outside the task, because it does not have a shell on a production host — it has a tool, and the tool has a boundary.
None of that required a decision about whether to trust the assistant. The boundary was already there because a human had already proved it was needed. That is usually how it goes: the control you build for one reason turns out to be load-bearing for a reason that did not exist yet.
Where the pattern stops being worth it
It is not free. Every tool you wrap is a Dockerfile, a wrapper script and an image to rebuild when the upstream tool moves. For something you run twice a year that is a bad trade; install it and move on. The pattern earns its keep when a tool touches credentials, when more than one person or system runs it, or when you want the thing it does to be reproducible six months from now.
And it is not a security boundary in the strong sense. A container is not a virtual machine, a mounted Docker socket hands over the host, and a tool that needs broad cloud permissions still has broad cloud permissions once it starts. What the pattern buys is narrower and more useful than it sounds: it makes the blast radius of each tool something you chose on purpose, wrote down, and can point at.
Start with whichever of your tools holds the most dangerous credential. For most people that is the cloud CLI. Wrap that one, get the secret out of the command line, and see whether the next one is easier.