I run Claude Code on a production Ubuntu box that serves live web traffic through nginx. The first time I enabled Remote Control so I could drive a session from my phone, I had a sobering realization: I was about to hand an autonomous agent a shell on a server hosting real domains, with nobody watching the terminal in real time. One bad rm, one careless sed -i against an nginx config, one overeager "let me just clean this up," and a box with weeks of uptime is down.
The feature that stands between you and that outcome is the permission system in settings.json. Most people either ignore it (every action runs, full trust) or disable it entirely with --dangerously-skip-permissions because the prompts are annoying. Both are mistakes on a server that matters. This post is the configuration I wish I had on day one, plus the honest caveats about what this file can and cannot protect.
What settings.json actually does
Claude Code gates tool calls (file edits, file writes, bash commands) through a permission layer. You control that layer with three things:
defaultMode: how the agent behaves for any action not explicitly listed.defaultprompts you each time.acceptEditsauto-approves file edits.bypassPermissionsapproves everything (this is the dangerous one).allow: patterns that run without prompting.deny: patterns that are refused outright.
The rule that matters most: deny always beats allow. If a command matches both lists, it is denied. This is what lets you say "allow broad categories of work, but carve out specific things that must never happen."
The file lives in one of two places:
~/.claude/settings.jsonapplies globally to every session on the machine.<project>/.claude/settings.jsonapplies only inside that project directory.
I strongly prefer the per-project file. A global bypass you set months ago and forgot about is exactly the configuration that bites you when you start a session in the wrong directory. Scope the permissive rules to the project that needs them.
The core problem: pattern matching is not a security boundary
Here is the part most guides skip, and it is the single most important thing to understand.
A deny-list matches command patterns. Patterns are bypassable. Consider a rule that blocks deletion:
"deny": ["Bash(rm:*)"]
This stops rm -rf /var/www. It does not stop any of these:
find /var/www -type f -deletetruncate -s 0 /etc/nginx/nginx.conf> /etc/nginx/nginx.conf(shell redirect empties the file)python3 -c "import shutil; shutil.rmtree('/var/www')"- a script the agent writes to a temp file and then executes
You cannot enumerate every path to destruction. A language model that wants to clean up a directory has many ways to express that intent, and your deny-list only catches the ones you anticipated.
So treat settings.json as layer one: a speed bump that catches the obvious and the accidental. The real protection is at the operating system level. I will cover both, because you want both.
Best practice 1: Run Claude as an unprivileged user
This is the protection that actually holds when the pattern-matching fails. If Claude Code runs as a user that has no write permission to /etc/nginx and /var/www, then even if every JSON rule is bypassed, the kernel refuses the write. rm returns "Permission denied" from the OS, not from a pattern match. No clever command form gets around a missing write bit.
# create a dedicated user with no sudo and no membership in privileged groups
sudo useradd -m -s /bin/bash claude-agent
# give it a working directory it owns
sudo -u claude-agent mkdir -p /home/claude-agent/work
# do NOT add claude-agent to sudoers
# do NOT add it to www-data or any group that can write to /etc or /var/www
Run your sessions as that user. Most of /etc and /var/www is world-readable, so the agent can still inspect configs and serve its purpose, but writes to those paths fail at the kernel. The settings.json deny-list then becomes belt-and-suspenders on top of a wall, instead of being the only thing standing there.
Best practice 2: Turn on the sandbox
Claude Code ships with a sandbox that adds filesystem and network isolation, and it is off by default. For an unattended or remote session on a server that matters, this is the setting with the highest payoff. Enable it when you start the session. It confines the agent regardless of what the JSON says, which is precisely the property you want when the JSON is a leaky abstraction.
Best practice 3: Keep defaultMode on "default"
It is tempting to set bypassPermissions so you stop seeing prompts, especially when you are approving actions from a phone screen where tapping "allow" is frictionless. Resist this on a production box. With defaultMode set to default, anything you did not explicitly allow stops and asks. That means your deny-list is not the only thing protecting the system; unanticipated commands halt by default rather than running. The deny-list handles the known-dangerous, and the default-prompt handles the unknown.
Best practice 4: Protect secrets explicitly
On a server that does any kind of cloud or security work, reading a credential is as damaging as editing a config. An exposed IAM key, SSH private key, or .env file is a full compromise. If you allow broad reads for convenience, plug the secret paths back with explicit denies. Deny beats allow, so this works cleanly:
"deny": [
"Read(/home/youruser/.aws/**)",
"Read(/home/youruser/.ssh/**)",
"Read(/root/**)",
"Read(**/.env)",
"Read(**/credentials)"
]
A complete settings.json for a web server
This is the configuration I run, adapted for a general nginx web server. It allows the agent to read widely and work freely inside its own project directory, while blocking mutation of system paths, destruction, service disruption, and secret reads. Replace /home/youruser/project with your actual working directory.
{
"permissions": {
"defaultMode": "default",
"deny": [
"Edit(/etc/**)",
"Write(/etc/**)",
"Edit(/var/www/**)",
"Write(/var/www/**)",
"Read(/home/youruser/.aws/**)",
"Read(/home/youruser/.ssh/**)",
"Read(/root/**)",
"Read(**/.env)",
"Read(**/credentials)",
"Bash(sudo:*)",
"Bash(rm:*)",
"Bash(rmdir:*)",
"Bash(mv:/etc/**)",
"Bash(mv:/var/www/**)",
"Bash(cp:* /etc/**)",
"Bash(cp:* /var/www/**)",
"Bash(dd:*)",
"Bash(mkfs:*)",
"Bash(truncate:*)",
"Bash(shred:*)",
"Bash(tee:/etc/**)",
"Bash(tee:/var/www/**)",
"Bash(find:* -delete*)",
"Bash(find:* -exec rm*)",
"Bash(sed:*-i*/etc/**)",
"Bash(sed:*-i*/var/www/**)",
"Bash(chown:*)",
"Bash(chmod:*)",
"Bash(ln:*/etc/**)",
"Bash(ln:*/var/www/**)",
"Bash(systemctl stop*)",
"Bash(systemctl disable*)",
"Bash(systemctl restart*)",
"Bash(systemctl reload*)",
"Bash(nginx:*)",
"Bash(service:*)",
"Bash(kill:*)",
"Bash(pkill:*)",
"Bash(killall:*)",
"Bash(iptables:*)",
"Bash(ufw:*)",
"Bash(git push:*)",
"Bash(git reset --hard*)",
"Bash(git clean*)",
"Bash(crontab:*)",
"Bash(:*>/etc/**)",
"Bash(:*>>/etc/**)",
"Bash(:*>/var/www/**)",
"Bash(:*>>/var/www/**)"
],
"allow": [
"Read(/**)",
"Edit(/home/youruser/project/**)",
"Write(/home/youruser/project/**)",
"Bash(cat:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(find:*)",
"Bash(python3:*)",
"Bash(pip install:*)",
"Bash(curl:*)",
"Bash(systemctl status*)",
"Bash(nginx -t)"
]
}
}
A few deliberate choices in here worth explaining:
nginx -tis allowed, butnginx:*and allsystemctl restart/reloadare denied. Testing a config is read-only and safe. Reloading or restarting nginx on a box with weeks of uptime serving live domains is exactly the "break the whole server" event you are guarding against. Let the agent validate; do the reload yourself.find:*is allowed butfind ... -deleteandfind ... -exec rmare denied. Because deny beats allow, plain searching works while the destructive forms are blocked.- The redirect denies (
:*>/etc/**and friends) are best-effort. They try to catchecho something > /etc/nginx/nginx.conf. Per the caveat above, do not trust them to catch every form. They are there to stop the obvious case. - Reads are broad (
Read(/**)) with secrets carved back out. The agent can inspect any config or log it needs, but the credential paths are denied, and deny wins.
Validate your configuration
A malformed settings.json can silently fail to apply, which is the worst case: you think you are protected and you are not. After writing the file:
claude doctor # flags a malformed or unreadable settings file
Then, inside a running session:
/permissions # shows the resolved allow/deny rules actually in effect
I cannot stress this last step enough. The exact key names and glob syntax in settings.json have shifted across Claude Code versions. Do not trust any config you copied from a blog (including this one) without confirming it against /permissions in your installed version. If the rules you see resolved do not match what you wrote, the schema changed and your file is not doing what you think.
If you genuinely need write access to nginx or web roots
Sometimes the work actually requires editing /etc/nginx or /var/www. Do not poke holes in the locked-down setup to allow it. Instead, do that specific work in a normal attended session, where you approve each step and watch what happens, and keep the unprivileged user and the deny-list for everything else, especially anything remote or unattended.
The convenience of driving an agent from your phone and write access to live web infrastructure are two things worth keeping firmly apart. The blast radius of a rubber-stamped approval on a phone screen is the entire server.
The layered model, summarized
No single mechanism here is sufficient. The protection comes from stacking them:
- Filesystem permissions and an unprivileged user. The wall. Holds even when everything above it fails.
- The sandbox. Confinement independent of the pattern matching.
settings.jsondeny-list. The speed bump. Catches the obvious and the accidental.defaultMode: default. The catch-all. Unanticipated commands stop and ask instead of running.- Validation with
claude doctorand/permissions. Confirms the layers are actually in effect.
Run all five. The deny-list alone, which is what most people reach for first, is the weakest layer in the stack. It is necessary but nowhere near sufficient. The user account is what saves you.