Skip to content

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
Tested
  1. Create the user with a home directory and no sudo group membership:

    From your workstation
    ssh -t pi-hole-admin 'sudo useradd -m -s /bin/bash claude-agent'
  2. 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'
  3. 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'
  4. 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-admin one you defined earlier:

    ~/.ssh/config
    Host pi-hole-agent
    HostName 192.168.0.153
    User claude-agent
    IdentityFile ~/.ssh/claude-agent
    IdentitiesOnly yes

    Install 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/.ssh
    echo '$PUBKEY' | sudo -u claude-agent tee -a /home/claude-agent/.ssh/authorized_keys >/dev/null
    sudo -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 -t suppresses the terminal it just asked for, and sudo on 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_keys rather 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 sudo behind 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=yes makes 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 need sudo. Claude Code only ever uses pi-hole-agent.

Install Claude Code on your workstation using the instructions for your platform, then confirm it reaches the Pi:

From your workstation
claude --version

You already confirmed ssh pi-hole-agent 'id' lands in the agent account when you created it.

Nothing gets installed on the Pi.

Tested

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.

  1. Install the ACL tools. They aren’t present by default:

    From your workstation
    ssh -t pi-hole-admin 'sudo apt install acl'
  2. Apply the grant. Three setfacl calls, 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:...:rX on the log directory
    • X means “execute only on directories,” so the directory gets r-x and every log file gets r--.
    • A lowercase r here 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, so u: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:...:r on the log directory
    • Sets a default ACL, which is what survives log rotation.
    • Pi-hole rotates pihole.log daily and FTL.log weekly with create 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 .1 and .2 archives.
  • u:...:r on the query database
    • Needs no directory ACL: /etc/pihole is already world-traversable.
    • This grant also survives Pi-hole restarts. FTL resets the database to mode 0640 every time it starts, and chmod rewrites an ACL’s mask rather than removing named-user entries, so the read stays effective.
  • u:...:r on pihole-FTL.db-wal and pihole-FTL.db-shm
    • pihole-FTL.db runs in SQLite’s WAL journal mode, so a reader also needs to open these two sidecar files. Granting the .db file alone fails with Parse 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/pihole itself. That directory also holds pihole.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 .db grant does. FTL may recreate -shm on 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.
Per vendor docs

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:

From your workstation
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:

From your workstation
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;"
EOF

The 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:

StatusMeaning
1Blocked by a blocklist (gravity)
2Forwarded upstream
3Answered from cache
4Blocked by a regex filter
5Blocked 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.

Tested

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:

From your workstation
ssh -t pi-hole-admin "sudo -u claude-agent bash -c 'echo test >> /var/log/pihole/pihole.log'"
From your workstation
ssh -t pi-hole-admin 'sudo -u claude-agent head -n 1 /home/pi-admin/.ssh/id_ed25519'
From your workstation
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:

From your workstation
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 denied means the file is mode 0640, 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:

From your workstation
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:

From your workstation
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;'"
Per vendor docs

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:

.claude/settings.json
{
"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.

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 sudo commands 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
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 Code
prompt, for example `! sudo pihole -g`. That runs it in my terminal, where a password
prompt can actually reach me, and puts the output back into your context so you can
keep going.
You also can't change lists. `pihole allow` and `pihole deny` don't need root, but they
authenticate by reading `/etc/pihole/cli_pw`, which you can't read, so instead of failing
they 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 service
status 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:
```shell
pihole-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`:
```shell
pihole-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:
```shell
pihole-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:
```shell
pihole-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.