FifeRouter

4 September 2026 · deploys security

Deploying from CI without handing CI a shell

A GitHub secret is readable by anyone who can merge a workflow change. So the deploy key opens four verbs and refuses everything else.

Deploys used to run from a laptop: build the site locally, tar the source over ssh, rebuild on the box. Production ran a rebuild of whatever was in somebody's working tree, and which build is live had no answer.

Moving that into CI means giving GitHub Actions an ssh key. Which means giving it to everyone who can merge a change to a workflow file — a .github/workflows edit is code that runs with that key, and the review that would catch a malicious one is the same review that approves ordinary changes.

On this box, a shell would also mean reading the Postgres password and reaching the fastpdlc stack that shares the machine.

The forced command

authorized_keys pins the key to one script:

command="/opt/fiferouter-deploy/ci-deploy.sh",no-agent-forwarding,
no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding ssh-ed25519 AAAA...

Whatever the client asks for, that script runs. The requested command arrives in $SSH_ORIGINAL_COMMAND as data, and the script dispatches on it:

(empty)          apply-env, reading KEY=VALUE on stdin
deploy <tag> [owner]   pull that tag and roll the stack onto it
rollback         return to the previously running tag
status           report what is running
*                refused

Verified rather than assumed. bash -i, scp x y, status; cat /etc/shadow, deploy ../../etc and deploy a;rm all exit 1.

Validating the arguments

A verb that interpolates an unvalidated string into a command line is a shell with extra steps. Tags and owners are checked against a pattern on both sides:

[[ "$tag" =~ ^[A-Za-z0-9._-]{1,128}$ ]] || die "refusing tag '$tag'"

Both sides, because the workflow's check gives a good error where somebody is already looking, and the server's check is the one that holds when the caller is not the workflow.

The registry credential that does not persist

The box pulls images from a private registry, which needs a credential. Storing one is a standing secret on the server for the sake of a dashboard-adjacent convenience.

Instead the workflow pipes its own GITHUB_TOKEN over ssh — on stdin, never as an argument, because an argument is visible in ps on the far side for as long as the command runs. The script logs in, pulls, and logs out in a trap that fires on every exit path.

The token expires with the workflow run. Nothing standing is stored.

Failure is the interesting path

The bundle is unpacked to staging and proved complete before anything live is touched — an empty one would otherwise empty the webroot. The previous tag is recorded before the new one is written, so an unhealthy stack rolls itself back without waking anybody. The edge snippet is validated before reload, because an invalid one takes down fiferouter and fastpdlc.com together.

And the webroot is replaced by contents, never by renaming the directory. A bind mount follows the inode; that mistake once left Caddy serving a previous build while the deploy reported success.

What is deliberately not automated

ci-deploy.sh itself. It is the entry point pinned in authorized_keys, and a script that rewrites itself while bash is still reading it is a way to learn how bash buffers.

Its helper is shipped, though — the allowlist for the credential path lives in a sibling script that the deploy refreshes from the release bundle. That change came after the allowlist went stale twice, each time producing a failure only visible at the moment somebody was trying to configure something.

One file that needs a human, and it changes rarely. Everything downstream of it maintains itself.


← All posts