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.