Almost every project we ship eventually needs the same unglamorous thing: get a build artifact from 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 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:

  1. 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.
  2. Authentication should not depend on a secret that travels with every request. A signature proves possession of a key without exposing it.
  3. 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 keygen command.
  4. 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 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_keys actually carries a command= 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 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

SSH's command= restriction in authorized_keys can scope a key to one command, but it is hand-edited, per-key server config with no schema — nothing stops an unrestricted key from sitting unnoticed next to correctly restricted ones. A restricted session also still speaks a shell, so argument handling has to be gotten right by hand. Orosu makes the script allowlist the default, protocol-level behavior rather than an opt-in convention, and passes arguments as protocol fields with no shell for them to escape out of.
The attacker inherits exactly the scripts that client's key is configured to trigger, with whatever arguments the protocol allows for that script — never an arbitrary shell command, and never another client's scripts. That is a materially smaller blast radius than a compromised SSH key, which typically inherits a full login shell.
No. Orosu is for machines that are not already managed by a cluster controller — a VPS, a bare-metal box, a server running a mix of services outside Kubernetes. If your target already has a real deployment controller, that controller is the better tool.
WSS/TLS covers the connection by default, typically terminated at a reverse proxy. An optional end-to-end layer (X25519 + HKDF-SHA256 + ChaCha20-Poly1305) additionally protects script arguments, files, and output from that proxy itself, and is opt-in independently on the client and server.
Yes, under Apache-2.0. The server, the orosu-keygen CLI, and the GitHub Action are all on GitHub, along with an apt repository and prebuilt release binaries.

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.