Locking Down a CI Deploy User with rrsync and SSH Forced Commands

Posted on нед 30 август 2026 in Tech Recipes

Introduction

The CI has access to the prod environment for one purpose: deploying new artifacts. That access must be scoped to deployment and locked for everything else. Even with properly isolated CI infrastructure, mistakes happen — a leak of its secrets must not become a jump from CI to prod.

Case: Bsky Accessibility UI

The Bsky Accessibility UI is a small project: a social media interface that is easier to use with a screen reader. It is live on my Jaix domain, and every update is transferred from GitHub to my server by a GitHub action. The action copies a couple of static html/js/css files with rsync — and copying is the only thing it must be allowed to do on the server. If the SSH key is ever compromised (or, more likely, my GitHub account), this access must be unusable for anything beyond serving slightly poisoned static files to the UI users. The approach applies anywhere you want good deployment hygiene, so I'm leaving notes with the whats and whys for my future me.

How It Works

  1. The CI authenticates with a dedicated SSH key, belonging to a dedicated bsky-deploy user that exists for nothing else.
  2. sshd ignores whatever the client asks for and runs one fixed command instead: rrsync, a restricted rsync wrapper that confines all file operations to the web root.
  3. The restrict option on the key shuts down everything else SSH could offer — no terminal, no tunnels, no login scripts.
  4. The account itself is a dead end: locked password, no sudo, and a home directory it can read but not write.

The Deploy User

The GitHub action gets its own user, named bsky-deploy. Not root, and not a user shared with anything else — the user is created once, so make it fit for purpose:

useradd -m -s /bin/bash -U bsky-deploy
  • -m creates the home directory, necessary here to host the SSH configuration.
  • -s pins the login shell to /bin/bash, explicitly. Without it, useradd inherits the system default (/bin/sh on Debian/Ubuntu, via /etc/default/useradd), which would probably work — but the setup hinges on the shell sshd uses to run the forced command, so don't inherit a distro default.
  • -U creates a private group (same name as the user) so nothing is shared with other accounts.

Why Not nologin as the Shell?

A tempting alternative is /usr/sbin/nologin. It does not work here: sshd executes the forced command from authorized_keys through the user's login shell ($SHELL -c "..."). With nologin as the shell, the forced command never runs — nologin prints "This account is currently not available." and exits, which ends the rsync handshake with protocol version mismatch -- is your shell clean?.

Interactive login is still impossible without nologin. The forced command intercepts every SSH request, no-pty (bundled in restrict) blocks terminal allocation, and the account has no password — useradd leaves it locked.

The bash Security Caveat

Choosing bash comes with one caveat, and the rrsync man page dedicates a whole section to it (BASH SECURITY ISSUE): when sshd runs the forced command through bash, a non-interactive sshd-invoked bash sources ~/.bashrc before executing the command. So if the deploy user could ever write /home/bsky-deploy/.bashrc, its contents would run ahead of rrsync, forced command or not.

The man page offers two fixes:

  • use a simpler shell such as dash (with a warning to avoid "bash in disguise", e.g. /bin/sh symlinked to bash);
  • make sure the user has no means of placing files outside its restricted directory.

This setup takes the second route: the root-owned, read-only home described below means the deploy user cannot write ~/.bashrc at all, which is what makes pinning /bin/bash acceptable where the man page would otherwise push you toward dash. The -munge flag on the authorized_keys line is the belt-and-suspenders layer on top: even symlink tricks cannot land a bashrc.

The Deploy Key

ed25519 is the modern default: short keys, fast signatures, no known weaknesses, and no parameter choices to get wrong (unlike RSA key sizes or ECDSA curves). Generate a dedicated pair for the CI, with an empty passphrase (a robot will use it) and a comment identifying its purpose:

ssh-keygen -t ed25519 -C "github-actions-deploy@bsky.jaix.eu" -f /tmp/bsky-deploy-key -N ""

The private key goes into the CI secrets (DEPLOY_KEY, plus DEPLOY_HOST), the public key into the authorized_keys line shown below, and then both local files get shredded.

A Read-Only Home Directory

The deploy user does not need to write to its own home at all. Nothing in this workflow writes there — the forced command runs rrsync, which confines itself to the web root, and a non-interactive bash -c writes no history or cache files. The home only needs to be readable so sshd can reach .ssh/authorized_keys. So hand the whole home to root:

chown root:root /home/bsky-deploy
chmod 755 /home/bsky-deploy

mkdir -p /home/bsky-deploy/.ssh
chown -R root:bsky-deploy /home/bsky-deploy/.ssh
chmod 750 /home/bsky-deploy/.ssh

The user can read everything it needs but cannot create, rename, or delete anything in home — those operations need write permission on the directory itself, and the directory belongs to root. Two sshd details make this safe: StrictModes accepts root-owned files (it only rejects group/world-writable ones), and sshd reads authorized_keys with the user's privileges, which the bsky-deploy group on .ssh covers.

Note to self: key rotation now requires root.

The Web Root

The web root is where the deploy user drops artifacts and where Caddy picks them up:

mkdir -p /var/www/bsky.jaix.eu
chown bsky-deploy:bsky-deploy /var/www/bsky.jaix.eu
chmod 755 /var/www/bsky.jaix.eu

Note the contrast with the home directory: the web root is the one place the deploy user owns and can write, because writing there is its entire job.

Note to self: 755 on the directory is required — the x bit means "traverse/enter". Without it, neither rsync nor the web server can reach the files inside. The files themselves end up 644. The web server needs only read/traverse, which 755 provides.

The authorized_keys File

The lockdown lives in /home/bsky-deploy/.ssh/authorized_keys. The file contains a single line per key — in this case exactly one key for a single purpose. The structure is shown as a comment above the real line:

# command="<command>",<option>,<option>,... <key-type> <key-material>
# (no spaces between the options; exactly one space before the key type)
command="/usr/bin/rrsync -munge -no-lock /var/www/bsky.jaix.eu",restrict ssh-ed25519 AAAA...

What Is rrsync and Why?

rrsync is a restricted wrapper that ships with rsync itself (/usr/share/doc/rsync/scripts/rrsync if your distro doesn't install it to /usr/bin). It validates that the incoming command really is an rsync transfer and confines all file operations to the directory given as its argument. If it's missing on your system, copy it out of the docs dir (or zcat the .gz variant) into /usr/local/bin/rrsync — or write a tiny wrapper that checks SSH_ORIGINAL_COMMAND starts with rsync --server and rejects everything else, including empty commands (interactive logins).

The Options, One by One

command="..." first: without it, the key is a login. With it, whatever the client asks sshd to run — a shell, ls, anything — sshd throws it away and runs this instead; the client's request survives only as the SSH_ORIGINAL_COMMAND environment variable.

-munge concerns symlinks and gets its own subsection below.

-no-lock is operational rather than security-related: it relaxes something instead of closing it. rrsync takes a per-user lock in the restricted directory to keep two overlapping transfers from corrupting each other. The CI deploys strictly sequentially, so that threat doesn't apply — skipping the lock instead buys robustness against a stale lock file wedging a deploy. The man page marks it "useful with -munge", which is why it rides along.

restrict is about sshd's feature set. An SSH key is more than a file-transfer token — by default a key holder can ask sshd for a terminal, TCP tunnels, an agent socket, a per-login script, and an X11 channel, and each of those is an escape route from "copy files" to a "foothold on the server". restrict (available since OpenSSH 7.2, released in 2016 — check your server with ssh -V) turns all of them off at once, and — the reason to prefer it over spelling the five no-* options out — automatically includes any new restrictions future OpenSSH versions add. What it contains:

Option Mechanism Danger if left open
no-pty Interactive sessions need a pseudo-terminal to drive. If anything in the chain let a session through, a PTY would make it a usable shell.
no-port-forwarding SSH's -L/-R forward arbitrary TCP through the encrypted channel. The key turns the server into a proxy; an attacker reaches anything the server can reach, including internal services that trust the machine itself.
no-agent-forwarding SSH can place a socket to your local ssh-agent on the remote machine; anyone who can access it (root, or the account itself) can authenticate as you elsewhere while the connection lives. Key misuse far beyond this server. No agent on the CI side is worth stealing here, but forwarding a socket into a machine you don't fully control is a bad habit worth refusing on principle.
no-user-rc sshd executes ~/.ssh/rc on every login, before anything else. Arbitrary command execution through a file the user can write in their own home directory. (The root-owned home already makes it unwritable — layers.)
no-X11-forwarding SSH can carry the X11 display protocol. Same family as port forwarding, with a notoriously trusting protocol on top. Equally useless for a file-copying robot anyway.

Plus the account itself: locked password → no console, su, or password SSH login, and no sudo.

Two escape attempts are worth checking against the confined rsync session:

Path traversal with .. — closed by rrsync itself. Can an attacker escape the web root with a relative path, e.g. push to ../../home/bsky-deploy/.ssh/authorized_keys? No. Two lines of the rrsync source do the work: every incoming path is first stripped of leading slashes (so even absolute paths get anchored inside the restricted dir), and any path containing a .. component is then rejected outright — the script dies with do not use .. in ... (anchor the path at the root of your restricted dir) before rsync ever runs.

Symlinks — closed by -munge. The residual vector is symlinks, in two flavors:

  • Write-through: push a symlink into the web root pointing outside it (say, to the deploy user's home), then push files through it. No .. anywhere, so rrsync has nothing to reject. The damage is bounded by what the deploy user can write — which is exactly why the root-owned home matters: writes through such a symlink fail with permission denied, and planting a new authorized_keys (a key without the forced command — a real shell) is impossible. If you ever skip the root-owned home, at minimum root-own .ssh and authorized_keys themselves, or chattr +i the file.
  • Read-through (knowledge extraction): push a symlink pointing at any world-readable file (/etc/passwd, another site's config, the Caddyfile) and fetch it over HTTP. No write permission is involved, so the root-owned home does nothing here. Closing it on the web server side is not an option either: Caddy offers no built-in "do not follow symlinks" toggle for file_server — the docs and the issue tracker confirm it, and the only symlink knob that exists (reveal_symlinks under browse) is directory-listing cosmetics. Without -munge, the fallback would be maintaining a small wrapper script that injects --safe-links into the server-side rsync command.

The mechanism behind both: a symlink is just a small file whose content is a path, and rsync transfers that path verbatim. With -munge, the receiving rsync prefixes every received target with /rsyncd-munged/: /etc/passwd becomes /rsyncd-munged/etc/passwd, an absolute path that does not exist. The link still lands on disk but dangles — nothing to write through, nothing for Caddy to serve. The mangling is reversible (a later send un-munges), the client cannot opt out (rrsync's whitelist rejects --no-munge-links), and it is free here because the deploys push no symlinks. One caveat from the rsync man page: do not combine it with --safe-links on the receiver — munging makes every target absolute, so --safe-links would ignore all symlinks, legitimate ones included.

If the server instead has the old Perl rrsync (check with head -1 /usr/bin/rrsync), there is no -munge flag — then the wrapper script is the way after all.

Note to self: the directory argument to rrsync becomes the root of the session. Every path the client sends is interpreted relative to it, so the CI's rsync target must be ./. If you use the absolute path it would be doubled into /var/www/bsky.jaix.eu/var/www/bsky.jaix.eu/... and would fail. Ask me how I know.

Verifying the Lockdown

Three quick tests with the deploy key:

# 1. No shell: "PTY allocation request failed" + rrsync rejection, connection closes
ssh -i /tmp/bsky-deploy-key bsky-deploy@<server-ip>

# 2. rsync works (note the relative target):
rsync -avz --delete -e "ssh -i /tmp/bsky-deploy-key" /tmp/test.html bsky-deploy@<server-ip>:./index.html

# 3. No other commands: rrsync rejection, connection closes
ssh -i /tmp/bsky-deploy-key bsky-deploy@<server-ip> "ls -la"

One more for the symlink defense: push a symlink at /etc/passwd into the web root, then curl https://<site>/<linkname> — expect a 404, because the received link was munged into a dangling target. A 200 means the fix is not in effect.

If tests (1) and (3) fail to give a shell while (2) succeeds, the lockdown holds.

Auditing the Permissions

The lockdown depends on a handful of ownership/mode bits being exactly right, and they are easy to quietly break later (a well-meaning chmod -R, a restored backup). Cheap to audit, cheap to repair. Check as root:

# Ownership and permissions of the whole chain at once
namei -l /home/bsky-deploy/.ssh/authorized_keys

stat -c '%U:%G %a %n' /home/bsky-deploy /home/bsky-deploy/.ssh /home/bsky-deploy/.ssh/authorized_keys
stat -c '%U:%G %a %n' /var/www/bsky.jaix.eu

# Account state: locked password, no extra groups
passwd -S bsky-deploy          # must show "L" (locked)
id bsky-deploy                 # must show only the bsky-deploy group

# The forced-command line still carries its flags — empty output means one silently vanished
grep -o 'rrsync -munge -no-lock [^"]*",restrict' /home/bsky-deploy/.ssh/authorized_keys

Expected values:

Path Owner Mode
/home/bsky-deploy root:root 755
/home/bsky-deploy/.ssh root:bsky-deploy 750
/home/bsky-deploy/.ssh/authorized_keys root:bsky-deploy 640
/var/www/bsky.jaix.eu bsky-deploy:bsky-deploy 755

If any of these drifted, repair by re-running the corresponding commands from the setup sections above (they are idempotent), and re-lock the password with passwd -l bsky-deploy (already the useradd default).

Conclusion

A leaked CI deploy key is no longer a server compromise. The key authenticates exactly one user, that user can run exactly one command, that command can touch exactly one directory, and the account behind it all is a locked, sudo-less dead end with a home it cannot even write to. The worst case is poisoned static files on one site — annoying, but not a foothold.

The setup was developed for the Bsky Accessibility UI's very specific needs, and I'm still improving on it — the content may be updated in the future. Any ideas are welcome, and in the meantime, take what fits for your own pipelines.