At some point a pipeline needs to touch a real server — run the deploy, apply a migration, tail a log to confirm the rollout took. Increasingly the thing doing the touching isn’t a hand-written script but an agent: a job that hands the task to a model and lets it decide which commands to run. Either way the plumbing question is the same, and the usual answer is the wrong one.
The usual answer is SSH_PRIVATE_KEY in your CI secrets. It works, so it’s everywhere. But an SSH key is a bearer credential — whoever holds the bytes is you, with a full shell, on every host that trusts the key, from anywhere. Drop it into a CI runner and you’ve handed that identity to a third-party VM that every workflow in the repo can read, that logs in ways you don’t fully control, and that you can only revoke by rotating the key on every host. Give it to an agent on top of that — a model that reads untrusted input and can be steered by it — and the blast radius is your whole fleet, standing, until you notice.
There’s a better default for CI: put a scoped, expiring token in the pipeline, not a key. Here’s how that works with Termalin’s hosted MCP endpoint, and a GitHub Actions job you can copy.
The model: keyless, and nothing running on your machine
Termalin’s hosted MCP server is one HTTPS endpoint — https://termal.in/api/v1/mcp — that reaches servers you’ve enrolled through a Connector (a small reverse-tunnel agent you install once on each box). A caller authenticates with an API key and calls tools; the endpoint runs each one on your server keyless, over a short-lived SSH certificate minted per request and good for minutes. The server needs no open inbound port, and — the part that matters for CI — no Termalin desktop app has to be running anywhere. The tunnel and the CA do the work.
So the credential in your pipeline is never an SSH key. It’s a Termalin API key (tk_live_…) that:
- is scoped to specific servers — not “all my hosts,” just the ones this pipeline should reach;
- carries a command policy — full, an allowlist of commands, or read-only;
- expires — 30, 90 or 365 days, your choice;
- is revocable in one click, and every run through it is rate-limited, time-boxed, and written to your audit log.
Compromise the runner and what leaks is a token that does one job on a few hosts and dies on a schedule — not a key that owns everything forever. That’s the trade worth making. (Why “who holds the credential” is the load-bearing question is its own post.)
Setup, once
1. Install a Connector on each server. From the web cabinet (termal.in/account → Add → Tunnel) you get a one-line installer:
curl -fsSL https://termal.in/tunnel-install.sh | sudo TERMALIN_TOKEN=… sh
It installs a ~3 MB static binary as a service that dials out to Termalin and reconnects on its own. It only shuttles encrypted SSH bytes to your own sshd.
2. Mint a scoped API key. In the cabinet, open API keys & MCP and create a key. Set the scope to just the servers this pipeline touches, pick the command policy (start with read-only — you can widen it later), and give it an expiry. Copy the tk_live_… value; you won’t see it again.
3. Store it as a CI secret. In GitHub: repo Settings → Secrets and variables → Actions → New repository secret, name it TERMALIN_MCP_KEY. You’ll also want the target server’s id — it’s stable, so grab it once and keep it as a secret or a variable too:
curl -s https://termal.in/api/v1/mcp \
-H "Authorization: Bearer $TERMALIN_MCP_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"hosts_list","arguments":{}}}'
That returns each reachable server’s id, name, whether it’s online, and the default login user. The id is what ssh_exec takes as its host.
Calling it from GitHub Actions
The endpoint speaks JSON-RPC 2.0 over plain HTTPS, so any runner with curl can drive it — no SDK, no extra action. This job checks that a service is healthy after a deploy:
name: post-deploy-check
on:
workflow_run:
workflows: ["deploy"]
types: [completed]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Confirm the app is running
env:
TERMALIN_MCP_KEY: ${{ secrets.TERMALIN_MCP_KEY }}
HOST_ID: ${{ vars.PROD_HOST_ID }}
run: |
out=$(curl -sf https://termal.in/api/v1/mcp \
-H "Authorization: Bearer $TERMALIN_MCP_KEY" \
-H "Content-Type: application/json" \
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",
\"params\":{\"name\":\"ssh_exec\",\"arguments\":{
\"host\":\"$HOST_ID\",
\"command\":\"systemctl is-active myapp && curl -sf localhost:8080/health\"}}}}")
echo "$out"
echo "$out" | grep -q '"active"' || { echo "app is not healthy"; exit 1; }
ssh_exec takes host (the server id), command, an optional username (it defaults to the tunnel’s remembered user, else root), and an optional timeoutSeconds (default 60, up to 300). It returns the command’s combined output — and, on a non-zero exit, an [exit code: N] line — so your job can gate on it exactly as above.
For a deploy step you’d flip the key’s policy to an allowlist (say, git, docker, systemctl) and call ssh_exec with the release command. There are eight tools on the hosted endpoint in total — alongside ssh_exec and hosts_list there’s sftp_list / sftp_read / sftp_write for files (text, up to 512 KB), data_query / data_tables to check a database, and generate_password.
The same job, but agent-driven
If the “thing” running in CI is an AI agent rather than a fixed script — a headless Claude Code run, or something you built on an agent SDK — you don’t curl anything by hand. You point the agent’s MCP client at the same endpoint with the same key, and it gets those eight tools as callable functions. Then the job prompt is just the task: “the deploy workflow finished — confirm myapp is active and the health endpoint returns 200, and open an issue if not.” The agent calls hosts_list, then ssh_exec, reads the output, decides. The key still can’t do more than its scope and policy allow, every call is still recorded, and nothing on your side is holding an SSH key. The difference from the curl job is only who chooses the commands — the safety envelope is identical.
A least-privilege recipe
Because the token is cheap to mint and easy to revoke, use more than one:
- A read-only key for observers — health checks, log tails, “is the migration done yet?” jobs. Read-only means a steered agent or a leaked runner can look but not change anything.
- An allowlisted key for deploys — scoped to the release hosts, policy limited to the handful of commands a deploy actually runs.
- A short, single-purpose key for one-off releases — 30-day expiry, revoked the moment the migration ships.
Every call through every one of them lands in the audit log, attributed to the key, so “what ran in CI last night” is a record you hold, not a story the runner tells.
The honest limits
This isn’t a general-purpose remote shell, and pretending otherwise would bite you mid-pipeline:
- Each command is its own session.
cd /appon one call is gone by the next — chain what needs to share state into a singlecommand(cd /app && ./release.sh). - Commands are time-boxed to 300 seconds. A long-running build belongs in the runner; use the endpoint to trigger and check, not to babysit.
- File writes are text-only and capped at 512 KB. It’s for config and scripts, not shipping artifacts — push those the way you already do.
- No interactive or TUI programs.
vim,htop, anything expecting a live terminal won’t work; it’s one command in, its output back. - The hosted endpoint reaches only Connector-enrolled servers. A box without a Connector isn’t visible to it — which is also why it needs no key and no open port.
- Hosted MCP is a Pro or Team feature. The desktop app and its local MCP server are free; reaching servers from CI with no desktop running is part of the paid cloud.
None of these are surprises once you know them, and most are the flip side of the thing you wanted: bounded, keyless, revocable access instead of a standing shell.
Termalin is a cross-platform SSH client with a built-in MCP server, a key custodian and per-host agent policy. Its hosted MCP endpoint lets agents and pipelines reach your servers keyless, with no desktop running — download the app or read how it handles keys.