Almost every project we ship eventually needs the same unglamorous thing: get a build artifact from CI/CD onto a server and run it. Nobody budgets time for this — it is "just deployment" — so it tends to get solved once, under deadline pressure, with whatever is fastest to type. Usually that is an SSH private key dropped into CI secrets and a shell script that SCPs a file over and restarts a service. It works. It also does not scale past the first project, and we kept rebuilding a slightly different version of it for every client until we finally sat down and wrote the tool we actually wanted: Orosu.
From Japanese 降ろす (órosu) — "to unload" or "offload."
Orosu is a small Rust service — orosu-server — that you install once on a machine. Instead of CI holding an SSH key that opens a shell, CI holds an Ed25519 key that can trigger exactly one of a fixed set of scripts the machine's owner defined ahead of time, over an authenticated WebSocket connection. This post is about why that distinction matters enough to build a whole tool around it, and what it looks like in practice.
Key takeaways
- The problem isn't SSH itself — it's what SSH access implies. An SSH key that can log in to run one deploy script can run anything. Every credential that reaches a production box inherits the full blast radius of a shell, whether the job needs it or not.
- Orosu narrows that down to a closed set of predefined scripts. CI selects a script by name and passes arguments; it cannot supply an arbitrary command, no matter what the pipeline is compromised into sending.
- Authentication is a signature, not a secret on the wire. Every job is signed with the client's Ed25519 key. There is no password or shared secret an attacker can intercept mid-connection.
- It is one Rust binary with no runtime dependencies — installed via apt or a prebuilt binary from GitHub Releases, fronted by whatever reverse proxy already terminates TLS on the box.
- An optional end-to-end encryption layer covers the one gap TLS-at-the-proxy leaves open: keeping script arguments and output confidential from that proxy itself.
- Orosu is Apache-2.0 licensed and open source — we use it, and we would rather other teams stop reinventing this too.
The shape of the problem
Strip the branding off "deploy from CI" and it is always the same request: run this script, on that machine, with these files. The default way most teams satisfy that request accumulates risk in a specific, predictable pattern:
- An SSH private key goes into CI secrets. It usually is not scoped to one script — it is scoped to a shell, because scoping SSH to "only this one command" is fiddly enough that almost nobody actually does it.
- The same key, or a near-copy of it, gets reused across every repository and pipeline that needs to reach that server, because generating and rotating a distinct key per pipeline is more process than anyone wants to own.
- The deploy script itself gets copy-pasted from the last project, small differences accumulate, and nobody remembers which version is authoritative.
- Production becomes directly reachable from CI runners — a class of hosts an attacker specifically targets, because a compromised runner with SSH access is a compromised server.
- The day a key does leak, rotating it means finding every pipeline that might reference it, because there was never a registry — just secrets sprawled across however many repos accumulated a copy.
None of this is a skill problem. It is the default shape of "run a script on a remote machine from a pipeline" when the tool you reach for is a general-purpose remote shell. SSH is not wrong for logging in and debugging a box by hand. It is the wrong primitive for a CI job that only ever needs to trigger the same three or four deploy scripts.
What we actually wanted
Before writing any code, the requirements were less about cryptography and more about what a deploy credential should be allowed to do:
- A CI credential should not be able to open a shell. It should be able to trigger a script, by name, from a list the server owner controls — full stop.
- Authentication should not depend on a secret that travels with every request. A signature proves possession of a key without exposing it.
- Adding a new deploy target should not mean generating a new SSH key pair and threading it through server config by hand — it should be a config entry and a
keygencommand. - The server should be the source of truth for what can run, not the pipeline. A compromised or careless CI job should never be able to expand its own permissions.
That list is a WebSocket protocol, not an SSH replacement wearing a costume — which is exactly what Orosu turned into.
How it works
orosu-server runs on the target machine. It listens for WebSocket connections, verifies an Ed25519 signature on every incoming job, matches the job's script name against that client's configured allowlist, and — only if all of that checks out — runs the script with whatever arguments and attached files the job carried.
listen:
tcp: "127.0.0.1:8081"
clients:
- name: my-ci-client
secret_file: /etc/orosu/my-ci-client.pub
scripts:
- name: deploy
command:
- "bash"
- "/etc/orosu/scripts/deploy.sh"
That scripts: list is the whole security model in one place. my-ci-client can trigger deploy and nothing else — not a different script on the same box, not an arbitrary shell command, not a script belonging to a different client. If the CI pipeline that holds this client's key is compromised, the attacker inherits exactly one deploy script's worth of capability, not a login.
A client's key pair comes from orosu-keygen:
orosu-keygen --name my-ci-client --private-key-output my-ci-client.key --public-key-output my-ci-client.pub
The public half goes into the server config above; the private half becomes a CI secret, used only to sign requests — it never grants a session by itself. Triggering the script from GitHub Actions is the companion Action:
- name: Deploy
uses: orosu-ci/orosu@v0
with:
address: ${{ secrets.OROSU_SERVER_URL }}
script: deploy
key: ${{ secrets.OROSU_CLIENT_KEY }}
arguments: ${{ github.sha }}
That is the entire pipeline change: an SSH step becomes an Orosu step, and the server-side surface shrinks from "anything this key's shell can do" to "this one named script."
orosu-server itself is typically bound to localhost and fronted by a reverse proxy that terminates TLS and forwards the WebSocket upgrade — nginx, in the common case, the same box that is probably already doing this for whatever the deploy script restarts.
Why not just scope an SSH key with command=
SSH does technically support a command= restriction in authorized_keys, and we get asked why Orosu is not just that. Two reasons it falls short in practice, both about what happens around the happy path rather than the happy path itself:
- It is per-key, hand-edited server config, invisible to the pipeline author. Nobody enforces that every deploy key in
authorized_keysactually carries acommand=restriction — the file has no schema, and an unrestricted key sitting next to nine restricted ones looks identical at a glance. - A restricted SSH session still speaks a shell. Depending on the script and how
command=interacts with the arguments SSH forwards, the boundary between "run this one thing" and "run this one thing, with injectable arguments" is easy to get subtly wrong. Orosu's script arguments are protocol-level fields, not shell tokens assembled from an SSH command line — there is no shell in the path for an argument to escape out of.
Orosu is not claiming SSH is insecure. It is claiming that the safe version of "restrict SSH to one command" is enough extra ceremony that almost nobody does it consistently, and a tool whose only job is running one of a few predefined scripts can make that the default instead of an opt-in.
Confidentiality past the reverse proxy
WSS covers the wire, but TLS terminated at a reverse proxy in front of orosu-server — the standard setup above — means script arguments, uploaded files, and output are plaintext at that proxy. For most deploy scripts that is a non-issue; for some it is not (a deploy argument can be a credential the script forwards onward, a rollback token, a customer identifier). Orosu's answer is an optional end-to-end encryption layer — X25519 for key agreement, HKDF-SHA256 to derive session keys, ChaCha20-Poly1305 to encrypt — that sits on top of the WebSocket transport and is opt-in on both ends independently:
orosu-keygen --kind server --private-key-output server.key --public-key-output server.pub
The server's public key goes into its config and into the GitHub Action's server_key input. A server with encryption configured still serves clients that omit server_key exactly as before — there is no coordinated cutover, no flag day, and no breakage for a client that has not upgraded yet.
Security hardening, honestly
We treat this as a first-class part of the tool, not a checkbox. A few specifics, because "we care about security" is a claim we would rather back with what actually changed: the 0.7.0 release added low-order-point checks on the X25519 exchange (closing a known class of curve-based attack), hardened attachment extraction so a crafted or oversized zip cannot path-traverse out of its directory or exhaust disk via decompression, and made orosu-keygen write private key files with 0600 permissions regardless of the umask in effect. All three shipped as a drop-in upgrade — same config, same protocol, same CLI. The full history is in CHANGELOG.md.
What Orosu is not
Scope is a feature here, not a gap, but worth stating plainly. Orosu does not orchestrate rollouts, manage rollbacks, or understand your deployment topology — it runs a script you already wrote, and the script decides what a deploy means. It is not a Kubernetes-native tool; if your target is already a cluster with a real deployment controller, that controller is almost certainly the better answer and Orosu is solving a problem you no longer have. Where it earns its place is the machine that is not a cluster node — the single VPS, the bare-metal box, the "we will containerize this eventually" server that every agency and a lot of startups still run some part of their stack on.
Getting started
orosu-server and orosu-keygen ship as Debian/Ubuntu packages:
curl -fsSL https://packages.nerdy.pro/NerdyPro.gpg | sudo gpg --dearmor -o /usr/share/keyrings/nerdy-pro.gpg
echo "deb [signed-by=/usr/share/keyrings/nerdy-pro.gpg] https://packages.nerdy.pro/ stable main" | sudo tee /etc/apt/sources.list.d/nerdy-pro.list
sudo apt update
sudo apt install orosu
GitHub Releases also publish prebuilt binaries for machines that cannot add an apt source. The full setup walkthrough — generating keys, writing the server config, wiring up the GitHub Action — is on the project page.
Orosu is written in Rust, the same language behind dxpdf, our DOCX-to-PDF engine — it is where we reach when a tool has to be fast, predictable, and safe against input that is not fully trusted, like a client-attached file arriving over the wire.
Frequently asked questions
Deploying to a server you do not want to SSH into anymore?
That is exactly what Orosu is for. Read the full project documentation for the complete setup, browse the rest of our open-source work, or get in touch if you want us to look at how your team ships to production — deploy pipelines built under deadline pressure are a specific, common finding in an AI code audit, and Orosu is the fix we reach for as much as we recommend it.
Ilya Nixan is Founder & Lead Developer at Nerdy Production, a Flutter-first agency that also builds and maintains the infrastructure tooling — like Orosu and dxpdf — that its own delivery work runs on.

