Give Claude Code Pi-hole Access Over SSH With a Dedicated User
This page combines the steps from Give Claude Code Access to Pi-hole into a single step-by-step tutorial for users who:
- Run Claude Code on their workstation
- Connect to the Pi over SSH as a dedicated unprivileged account
Create the Agent User
Section titled “Create the Agent User”-
Create the user with a home directory and no
sudogroup membership:From your workstation ssh -t pi-hole-admin 'sudo useradd -m -s /bin/bash claude-agent' -
Tighten its home directory so other accounts can’t read its session data:
From your workstation ssh -t pi-hole-admin 'sudo chmod 750 /home/claude-agent' -
Give it a working directory, since Claude Code needs somewhere it can write:
From your workstation ssh -t pi-hole-admin 'sudo -u claude-agent mkdir -p /home/claude-agent/pihole' -
Set up access to the account:
Give the account its own SSH key so your workstation can reach it directly:
From your workstation ssh-keygen -t ed25519 -f ~/.ssh/claude-agent -C "claude-agent@pi-hole"Add a second host alias for it, alongside the
pi-hole-adminone you defined earlier:~/.ssh/config Host pi-hole-agentHostName 192.168.0.153User claude-agentIdentityFile ~/.ssh/claude-agentIdentitiesOnly yesInstall the key through your administrative session instead:
From your workstation PUBKEY="$(cat ~/.ssh/claude-agent.pub)" && \ssh -t pi-hole-admin "sudo -u claude-agent mkdir -p -m 700 /home/claude-agent/.sshecho '$PUBKEY' | sudo -u claude-agent tee -a /home/claude-agent/.ssh/authorized_keys >/dev/nullsudo -u claude-agent chmod 600 /home/claude-agent/.ssh/authorized_keys"The key goes into a shell variable rather than through a pipe on purpose. Piping into
ssh -tsuppresses the terminal it just asked for, andsudoon the far end fails with the same password prompt error this page opened with.Safe to re-run: doing this twice appends a duplicate identical line to
authorized_keysrather than breaking anything. If you’re not sure whether the key’s already there, dedupe it:From your workstation ssh -t pi-hole-admin "sudo -u claude-agent bash -c 'sort -u -o /home/claude-agent/.ssh/authorized_keys /home/claude-agent/.ssh/authorized_keys'"Leaving the account password-locked is the point. The key is the only way in, and it opens a shell with no
sudobehind it.Confirm the alias lands in the right account, and do this before touching any Pi-hole data:
From your workstation ssh -o ConnectTimeout=8 -o BatchMode=yes pi-hole-agent 'id'That should report
uid=... (claude-agent), which is sshd enforcing the identity rather than Claude Code choosing it.BatchMode=yesmakes this fail fast and non-interactively instead of hanging on a password prompt, and running it now isolates “is the SSH layer working” from “does this account have the Pi-hole permissions it needs,” two separate failures that are easy to conflate if the first query you try is a real one.Setup commands on the rest of this page use
pi-hole-admin, because they needsudo. Claude Code only ever usespi-hole-agent.
Install Claude Code
Section titled “Install Claude Code”Install Claude Code on your workstation using the instructions for your platform, then confirm it reaches the Pi:
claude --versionYou already confirmed ssh pi-hole-agent 'id' lands in the agent account when you created it.
Nothing gets installed on the Pi.
Grant Read Access
Section titled “Grant Read Access”POSIX ACLs grant one account read access to one file. Unlike group membership, an ACL can be scoped per file, is revocable without touching the account, and grants no write access anywhere.
Every command in this section runs with sudo on the Pi, over SSH as pi-hole-admin.
-
Install the ACL tools. They aren’t present by default:
From your workstation ssh -t pi-hole-admin 'sudo apt install acl' -
Apply the grant. Three
setfaclcalls, chained so the run stops if any one fails:From your workstation ssh -t pi-hole-admin 'sudo setfacl -R -m u:claude-agent:rX /var/log/pihole && \sudo setfacl -m d:u:claude-agent:r /var/log/pihole && \sudo setfacl -m u:claude-agent:r /etc/pihole/pihole-FTL.db && \sudo setfacl -m u:claude-agent:r /etc/pihole/pihole-FTL.db-wal /etc/pihole/pihole-FTL.db-shm'
Explanations:
-R -m u:...:rXon the log directoryXmeans “execute only on directories,” so the directory getsr-xand every log file getsr--.- A lowercase
rhere instead breaks the whole grant, and fails in a way that looks like the ACL never applied: a named-user entry replaces whatever the account was getting from the directory’s “other” class, sou:agent:r--on the directory strips the execute bit it needs to enter the directory at all, and every read below it is denied.
d:u:...:ron the log directory- Sets a default ACL, which is what survives log rotation.
- Pi-hole rotates
pihole.logdaily andFTL.logweekly withcreate 640 pihole pihole, building a brand new file each time. An ACL set only on today’s file is gone tomorrow. A default ACL is inherited by every file created in the directory afterward, including the compressed.1and.2archives.
u:...:ron the query database- Needs no directory ACL:
/etc/piholeis already world-traversable. - This grant also survives Pi-hole restarts. FTL resets the database to mode
0640every time it starts, andchmodrewrites an ACL’s mask rather than removing named-user entries, so the read stays effective.
- Needs no directory ACL:
u:...:ronpihole-FTL.db-walandpihole-FTL.db-shmpihole-FTL.dbruns in SQLite’s WAL journal mode, so a reader also needs to open these two sidecar files. Granting the.dbfile alone fails withParse error in 3rd command line argument: unable to open database file (14), an error that reads like a syntax problem and isn’t.- This has to be a per-file grant, not a default ACL on
/etc/piholeitself. That directory also holdspihole.toml, which this guide separately and correctly keeps off limits (it stores API password hashes in plaintext); a directory-wide default ACL would silently expose it to any account with the default grant. - Not yet confirmed whether this grant survives an FTL restart the way the
.dbgrant does. FTL may recreate-shmon restart the same way it resets the database’s mode, which would drop the ACL along with it. If queries that worked start failing with the same error after restarting FTL, re-run this command.
Read Query History
Section titled “Read Query History”pihole-FTL ships an embedded SQLite shell, so no separate sqlite3 package is needed.
Always pass --readonly, which makes writes impossible at the connection level.
Run this as the account you just granted access to. It doubles as proof the grant works.
Replace YOUR-DEVICE-IP with the IP of a device on your network you want to check:
ssh pi-hole-agent "pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db \ \"SELECT datetime(timestamp,'unixepoch','localtime'), client, domain, status FROM queries WHERE client = 'YOUR-DEVICE-IP' ORDER BY timestamp DESC LIMIT 20;\""The nested double quotes here are easy to get subtly wrong. A heredoc avoids the extra escaping layer and matches the multi-line SQL formatting above verbatim, so it’s the better default when you’re calling this from your own machine rather than already logged in:
ssh pi-hole-agent bash -s <<'EOF'pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db \ "SELECT datetime(timestamp,'unixepoch','localtime'), client, domain, status FROM queries WHERE client = 'YOUR-DEVICE-IP' ORDER BY timestamp DESC LIMIT 20;"EOFThe queries view resolves the internal integer IDs back to real domain and client strings, so you don’t need to join anything.
status stays numeric.
The values are:
| Status | Meaning |
|---|---|
1 | Blocked by a blocklist (gravity) |
2 | Forwarded upstream |
3 | Answered from cache |
4 | Blocked by a regex filter |
5 | Blocked by the denylist |
The database lags live traffic by up to 60 seconds because FTL flushes its in-memory buffer on database.DBinterval, which defaults to 60.
Verify the Boundaries
Section titled “Verify the Boundaries”Confirm what the account can’t do as that account (rather than as yourself).
Each of these should report Permission denied.
Run them one at a time, since each is expected to fail and chaining them would stop at the first:
ssh -t pi-hole-admin "sudo -u claude-agent bash -c 'echo test >> /var/log/pihole/pihole.log'"ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /home/pi-admin/.ssh/id_ed25519'ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /etc/shadow'Replace /home/pi-admin with your own home directory on the Pi.
Then check pihole.toml:
ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /etc/pihole/pihole.toml'The result depends on your Pi-hole version, and both outcomes are expected:
Permission deniedmeans the file is mode0640, and the exclusion holds.- A line of config means the file is mode
0644, so it is readable by every account on the Pi. Nothing is broken, but the deny rule in the next section is doing real work, and you should treat those password hashes as exposed locally.
To see the grant itself:
ssh pi-hole-admin 'getfacl -p /var/log/pihole /etc/pihole/pihole-FTL.db*'If a query still fails after that, run this as the granted account to tell “the ACL isn’t there” apart from “my query syntax is wrong” in one shot, rather than debugging both possibilities against a real query at once:
ssh pi-hole-agent "getfacl /etc/pihole /etc/pihole/pihole-FTL.db* 2>&1; echo ---; pihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db 'SELECT 1;'"Configure Claude Code
Section titled “Configure Claude Code”Permission rules are enforced by Claude Code, not the kernel.
Claude’s own documentation notes that Bash patterns constraining arguments are fragile, so these rules are defense in depth: they stop an honest mistake, and the ACL is what holds when a rule is bypassed.
That ordering matters most for pihole.toml, since on a 0644 host the deny rule is the only thing standing between an agent and your password hashes.
Add to .claude/settings.json in the project directory you run Claude Code from:
{ "permissions": { "deny": [ "Bash(ssh pi-hole-admin:*)", "Bash(ssh -t pi-hole-admin:*)" ], "allow": [ "Bash(ssh pi-hole-agent:*)" ] }}The deny rules keep Claude Code off the administrative alias, so it can’t reach the account that has sudo.
claude-agent account’s own permissions on the Pi restrict the agent alias itself, not the contents of this file.
Give the Agent a Context File
Section titled “Give the Agent a Context File”Access alone isn’t enough. An agent that doesn’t know how your network is put together can search for the wrong thing and report a confident non-answer.
The file below is a working CLAUDE.md for this setup.
Copy it to the directory you run Claude Code from and replace the host details with your own.
- The Privileges section stops the agent from wasting tokens on
sudocommands it can’t run.- Carries two commented-out blocks. Uncomment the matching one if you add either optional grant below.
- The Gotchas section encodes the failures that look like real findings rather than mistakes.
CLAUDE.md
# Pi-hole environment
Context for Claude Code working on this Pi-hole install.Replace the placeholder values with your own before you use this.
## Host
- Pi-hole host: `pi-hole` at `192.168.0.153`, interface `eth0`- Router and gateway: `192.168.0.1`- Versions: Core v6.4.3, Web v6.6, FTL v6.7- My login account: `pi-admin`
## Privileges
You have no `sudo` access, and `sudo` cannot prompt for a password in your shell.Don't try privileged commands. They will fail with `a terminal is required to read the password`.
If something genuinely needs root, print the command and ask me to run it.Tell me to run it in shell mode by typing `!` followed by the command at the Claude Codeprompt, for example `! sudo pihole -g`. That runs it in my terminal, where a passwordprompt can actually reach me, and puts the output back into your context so you cankeep going.
You also can't change lists. `pihole allow` and `pihole deny` don't need root, but theyauthenticate by reading `/etc/pihole/cli_pw`, which you can't read, so instead of failingthey hang waiting for a password prompt you can't see. Hand those commands to me the same way.
<!-- If you granted read access to /etc/pihole/cli_pw, uncomment this:You can run `pihole allow`, `pihole deny`, `pihole enable`, and `pihole disable` directly,with no sudo. Ask me first before disabling blocking or removing a list.-->
<!-- If you set up the diagnostic wrapper, uncomment this:`sudo -n /usr/local/sbin/pihole-agent-diag` runs without a password and prints FTL servicestatus plus the last 100 FTL log lines. It takes no arguments.-->
## What you can read
Granted explicitly by ACL:
- `/var/log/pihole/pihole.log`: raw dnsmasq query log- `/var/log/pihole/FTL.log`: FTL engine log, for startup and DNSSEC errors- `/etc/pihole/pihole-FTL.db`: long-term query database
World-readable, no grant needed:
- `/etc/pihole/gravity.db`: blocklists, groups, clients, adlists
## What you must not read
`/etc/pihole/pihole.toml` is off limits.It stores `webserver.api.pwhash` and `webserver.api.app_pwhash` in plain text, and reading the file pulls my password hashes into this transcript.
To read a single config value, read only that value:
```shellpihole-FTL --config webserver.api.app_sudo```
## Querying the data
The standalone `sqlite3` binary isn't installed. Use the one built into FTL, always with `--readonly`:
```shellpihole-FTL sqlite3 --readonly /etc/pihole/pihole-FTL.db \ "SELECT datetime(timestamp,'unixepoch','localtime'), client, domain, status FROM queries WHERE client = '203.0.113.42' ORDER BY timestamp DESC LIMIT 20;"```
The `queries` view resolves internal integer IDs to real domain and client strings, so no joins are needed.`status` is numeric: `1` blocked by blocklist, `2` forwarded, `3` from cache, `4` blocked by regex, `5` blocked by denylist.
The database lags live traffic by up to 60 seconds (`database.DBinterval`).If a query you just made is missing, wait a minute before concluding it didn't happen.
Read `gravity.db` the same way:
```shellpihole-FTL sqlite3 --readonly /etc/pihole/gravity.db "SELECT id, address, enabled FROM adlist;"```
## Gotchas that have cost time before
### Devices appear under their Tailscale IP
Tailscale is in use here.While a device is connected to the tailnet, its DNS queries arrive over `tailscale0` and the query log records its `100.x.x.x` address, not its LAN IP.Filtering by the LAN IP returns nothing at all, which looks identical to "this device isn't using Pi-hole."
Run `tailscale status` first and map the device both ways.Active peers show the LAN address they connected from, such as `direct 203.0.113.42:41641`.
### Tailnet devices fall back to the Default group
Pi-hole matches clients by MAC address, and MAC lookups depend on ARP, which doesn't exist on `tailscale0`.A device connected over Tailscale can't be matched to a client entry created by MAC, so it falls back to the `Default` group no matter what group I assigned it.
Check which group a client actually resolves to before explaining its blocking behavior.FTL also matches clients by interface and prefers the interface queries actually arrive on, so a single client entry of `:tailscale0` covers every tailnet device at once.
### Blocklists are split across group bundles
Adlists are grouped into reusable bundles rather than assigned per device.Read the current mapping instead of assuming:
```shellpihole-FTL sqlite3 --readonly /etc/pihole/gravity.db \ "SELECT g.name, group_concat(a.id) FROM 'group' g LEFT JOIN adlist_by_group abg ON abg.group_id = g.id LEFT JOIN adlist a ON a.id = abg.adlist_id GROUP BY g.id;"```
Group membership is a union, not an override.A client in two groups gets the blocklists of both.
### Blocklist files use Adblock Plus syntax
The HaGeZi lists in `/etc/pihole/listsCache/` and the `gravity` table store entries as `||example.com^`, not bare domains.Grepping for `^example\.com$` returns nothing and looks like a real absence.Match on `\|\|example\.com\^` instead.
### Allow and deny changes don't need a gravity rebuild
`pihole allow` and `pihole deny` write to the `domainlist` table and FTL picks them up immediately.Only adlist changes need `pihole -g`, which takes several minutes and rewrites the whole gravity database.
### Comments reject some punctuation
`pihole allow --comment` accepts only `[a-zA-Z0-9_#:/.,\ -]`.A `+` in a comment fails the whole command.