</>CodeWithKarani

Access is a liability, not a convenience

Karani GeoffreyKarani Geoffrey8 min read

The meeting was going well until the last ten minutes. We had walked a Nairobi financial services group through the whole ERPNext build, the reporting they had asked for, the migration plan for their old system. Then I said the sentence I had said a hundred times before without thinking about it: "we will just need SSH access to the dev server."

The room changed. Not dramatically, and nobody said no. But the head of IT wrote something down, the procurement lead asked who else in my company would have that access, and a meeting that had been about software became a meeting about risk. Three weeks of back and forth followed. In the end we did not get access, and their team ended up pulling and migrating by hand every time we shipped a change, which is exactly the arrangement nobody wanted.

I got that wrong, and not in the way I initially thought. My mistake was not phrasing. It was that I had asked for something I did not want to be given. If you build systems for other people's businesses, the credential you are holding is not a convenience you have earned. It is a liability you have accepted, and there is a much better answer available: deploying Frappe apps to a client server you are not allowed to touch, using a mechanism where the credential never exists in the first place.

an SSH key on a client's production server is a permanent, transferable grant of everything that server can reach, held by you, defended by you, and explained by you after any incident.

  • The key does not grant "deployment". It grants a shell, and a shell grants the database, the files and every customer record in them.
  • Once it exists it can be copied, and a copy leaves no trace. Rotating it needs the client's cooperation anyway.
  • "Give me access" moves a software conversation into a procurement risk conversation, and you will lose weeks there.
  • A deployment story with no inbound access and no vendor-held credential is a thing you can sell. It answers a security questionnaire before it is written.

What one SSH key actually grants

We talk about SSH access as though it is scoped to the task we intend to use it for. It is not scoped to anything. An authorised key on a server is an authentication mechanism, and authentication is not authorisation. What it grants is whatever the account it logs into can do, which on a Frappe server is a great deal more than deployment.

What one SSH key actually grants Four concentric rings drawn from the inside out. At the centre is a single SSH key on a laptop. The next ring is a shell as the bench user. The next is the site database, reachable from that shell without any further password. The outermost ring, marked as the risk boundary, is every customer record stored in the site. Each ring is labelled to the right with a leader line. SSH key Every customer record in the site Names, ID numbers, balances, payroll, supplier pricing The site database Credentials sit in site_config.json, readable by that shell A shell as the bench user Read, write and execute anything that user owns One key file on one laptop Copyable in a second, and the copy leaves no trace Nothing between the rings asks again There is no second authentication step between the shell and the data. The key is the only gate, and it is on your laptop. You asked for permission to run one command. You were granted the whole outer ring, permanently.
The rings are not a hierarchy of permissions you can step down through. They are one grant, drawn at four levels of consequence.

None of this is an argument that SSH is badly designed, and hardening it properly is worth doing on any box you run - locking down a fresh VPS in twenty minutes covers that. The point is narrower: hardening changes how hard the key is to misuse, not what it grants once used.

On a standard Frappe install the bench user owns the apps, the sites directory and the Python environment. The database password for the site sits in plain text in that site's configuration file, which is exactly how the framework is designed to work and exactly why a shell as that user is a shell into the database. Anyone who reaches the shell reaches the customer records, without ever running an exploit. They just read a file and connect.

So when a client's security lead hesitates at "just SSH access", they are not being obstructive and they are not confused about what you intend to do with it. They are reading the grant correctly and you are not.

Why the request lands so badly in procurement

There is a specific reason this sentence causes trouble in an enterprise conversation, and it has nothing to do with trust in you personally.

The moment a vendor holds a credential to a production system, that vendor enters the client's risk register. Somebody now has to answer questions in writing: who at the vendor holds it, what happens when that person leaves, is it on a personal laptop, is that laptop encrypted, is there an offboarding process, does the vendor carry cyber liability cover, what is the notification obligation if the vendor is breached. In a regulated environment - and in Kenya that increasingly means anyone touching customer data under the Data Protection Act - the client also has to treat you as a data processor and paper the relationship accordingly.

None of those questions are unreasonable. All of them take weeks. And the honest thing to notice is that every one of them exists only because of a request you made for your own convenience. You created the risk item. You are now the reason the project is in a procurement queue.

The alternative is not "refuse to deploy". It is to arrive with an architecture where the question never comes up, because there is nothing to register.

Blast radius, both ways

Let me be fair to the model I am arguing against, because the honest comparison is not "SSH is dangerous, runners are safe". Both models have a failure mode. They are just different failures with very different shapes.

Blast radius of a leaked CI secret under each deployment model Two panels with identical layout. On the left, the SSH model: a leaked repository secret is a private key, which yields a shell, then the database, then all customer records, and the leak is silent. On the right, the runner model: there is no inbound credential to leak, so a dumped secret store yields nothing on the server, but push access to the repository still means code execution, which is a separate risk covered elsewhere. SSH model: the secret is a key Runner model: there is no inbound key A repository secret leaks Exfiltrated by any workflow in the repo, or by any maintainer A repository secret leaks Same exposure, same attacker, same day It is a private key for port 22 Usable from anywhere on the internet, immediately There is nothing to use it against No inbound port, no account, no credential to the server Shell, database, files, customer records The whole outer ring, in one step The real risk sits elsewhere Push access to the repo is code execution on the runner Nobody finds out A copied key is indistinguishable from the original The client can end it unilaterally Stopping one service on their box stops everything The honest comparison Neither model is risk free. The SSH model concentrates the risk in a credential that survives, travels and cannot be revoked without the client. The runner model moves the risk to your repository, where you can see it and gate it.
Both columns start with the same bad day. What differs is whether the bad day reaches the client's database.

The runner model is not free of risk, and I will not pretend otherwise. If your client's server runs an agent that executes whatever your repository tells it to, then push access to your repository is command execution on their machine. That is a real and serious property, it deserves its own article, and it is the subject of hardening a self-hosted runner against the push-access problem.

But notice the difference in shape. The SSH risk is a bearer credential: it works for whoever holds it, from anywhere, with no approval step, and its theft is invisible. The runner risk is an execution path you can put gates on - branch protection, tag protection, an approval the client themselves grants. One risk is a fact about a file. The other is a workflow you control and can constrain.

The commercial argument, which is the real one

I have made the security case because it is true, but it is not why I changed how I work. I changed because of what happened in the next procurement conversation after that one.

Different client, similar size, same stage in the process. When we got to deployment I said: your server will connect out to our release system, pull the tagged release, and run the upgrade. Nothing connects in. We hold no credential to your environment. Your IT team installs the agent, and can stop it at any time without telling us. Would you like the exact commands now or with the contract?

That took ninety seconds and it did not go to a risk register. It also did something I did not expect: it changed how they saw the rest of the proposal. A vendor who has clearly thought about the shape of their own access reads as a vendor who has thought about other things too.

This is worth being blunt about, because a lot of independent developers and small firms across East Africa are competing for exactly these accounts against much larger integrators. You will not out-resource them. You can absolutely out-architect them on this specific point, because the large integrator will usually turn up asking for a jump host, a service account and a VPN profile, and you will turn up asking for nothing.

What it costs you

Every recommendation has a bill, and this one has three lines on it.

You lose the midnight fix. When something breaks in production you cannot open a terminal and poke at it. You will diagnose from logs the client sends you, or from a screen share, and it will be slower. The first time this happens you will want the key back. Do not ask for it. Build better diagnostics instead, because the second time it happens the diagnostics will pay for themselves.

Setup moves earlier and involves someone else. Somebody on the client's side has to run an install once, and you have to write instructions good enough for a person you have never met to follow without you watching. That is real work, and it is work you do before you have been paid.

You have to actually have a pipeline. Deploying by hand over SSH tolerates a mess of undocumented steps living in your head. A pull-based or runner-based deploy does not. Every step must be written down in a script that runs unattended, which means you will discover, in week one, three things you have been doing manually for years without noticing. That discovery is unpleasant and it is the single most valuable part of the exercise.

The trade is: slower to fix, faster to ship, and no longer the answer to the question "who else could have touched this data".

Where this goes next

The rest of this argument is mechanical rather than philosophical. If the client's server is going to initiate everything, something on it has to do the initiating, and the shape of that thing determines everything else. That component is a runner, and understanding what a runner actually is, and why a self-hosted one needs no inbound connection is the single idea that makes the whole approach work.

If you want the finished implementation rather than the reasoning - the workflow file, the deploy script, the checklist you hand to the client's IT team and the security conversation to have before they raise it themselves - that is all in the full walkthrough of deploying to a client server you cannot touch.

And if you own the box yourself, none of this applies to you. Use SSH. It is simpler, it is well understood, and the SSH deployment path for Frappe is written up honestly, including what it costs. The argument in this article is about servers that belong to someone else.

Frequently asked questions

The client offered me full root access. Should I take it?
No. Take the narrowest access that lets you do the work, and prefer none at all for production. Root on a client's server means every future incident on that box starts with a question about you, and you will be answering it without logs of your own. If you need root for the initial install, take it for that window, hand it back, and put the change in writing.
Is refusing server access not just making my job harder?
It makes the first week harder and every week after that easier. The work moves from ad-hoc SSH sessions to a repeatable pipeline, which you needed anyway the moment a second person joined the project. What you lose is the ability to fix things by hand at midnight without telling anyone, and that was never an asset.
What do I say when procurement asks how I deploy without access?
That the client's server initiates every connection outward, that no inbound port is opened for you, that no credential to their environment exists on your side, and that they can stop the whole mechanism themselves with one command. Those four sentences answer most of a vendor security questionnaire before it is asked.
Does this apply to a small business client too?
The security argument matters less, but the liability argument is identical and the commercial argument is stronger. A small business owner who loses data will look for someone to blame, and a vendor holding the only SSH key is the obvious candidate. Not holding the key is cheap insurance regardless of client size.
#Security#SSH#Deployment#Frappe#ERPNext
Keep reading

Related articles