Secrets, environments and required reviewers
Most developers meet GitHub secrets the same way. You need a database password in a workflow, you find Settings, Secrets and variables, Actions, you paste it in, the field turns into eight asterisks and never shows you the value again. It feels like a vault. That feeling is the problem, because it is not a vault, and the difference between the two kinds of secret GitHub offers is the difference between a value your whole team can read and a value that will not be released until someone at the client's organisation says so.
This is the piece that makes the whole no-access approach acceptable to a security team. Not the encryption. The gate. If you are building towards deploying Frappe apps to a client server you are not allowed to touch, this article is where the client stops being a bystander to your pipeline and becomes the thing that authorises it.
repository secrets are readable by anyone who can push to the repository. Environment secrets are held back until the environment's protection rules pass, so put anything that matters in an environment and make the client a required reviewer on it.
- Write access equals secret access. There is no third state, whatever the asterisks suggest.
- An environment is the only place in GitHub Actions where a human approval can stand between a workflow and a value.
- Up to six required reviewers, any one of whom can approve. Unapproved jobs fail after 30 days.
- Add deployment tag protection so the environment can only ever be reached from a real version tag, not from a branch someone pushed.
Where a secret lives is who can read it
GitHub gives you three scopes, and they nest. What changes between them is not how strongly the value is encrypted, which is the same everywhere. What changes is the audience.
The sentence to internalise is GitHub's own: any user with write access to your repository has read access to all secrets configured in it. Not because the UI shows them, but because they can push this and read the output:
- run: echo "${{ secrets.DB_ROOT_PASSWORD }}" | base64
The mask does not catch that, because the masked string is the plaintext and what appears in the log is its base64 encoding. GitHub is explicit that automatic redaction is not guaranteed. Redaction is there to stop you leaking a secret by accident. It is not a control against anyone who has decided to extract one.
There is a nastier variant of this on public repositories, where a fork's pull request can be manoeuvred into running with the base repository's secrets. That is worth knowing in its own right: the pwn request, and how fork pull requests steal your secrets.
So if a junior developer on a three-month contract has push access to the repository that deploys your client's ERP, that developer has the client's database credentials. Not "could escalate to". Has.
Environments, and the only gate GitHub gives you
An environment is a named target you attach to a job. Creating one takes ten seconds under Settings, Environments, and attaching it takes one line:
jobs:
deploy:
runs-on: [self-hosted, dev-client-local]
environment: production
steps:
- uses: actions/checkout@v5
- run: /home/frappe/deploy.sh ${{ github.ref_name }}
On its own that line does almost nothing. It groups deployments in the UI and gives you a URL to click. Its value is that it is the only hook in GitHub Actions where a rule can sit between a job and its execution. Four rules are available, and they evaluate before the job starts and before any environment secret is handed over.
Required reviewers. Up to six people or teams. Any one of them approving is enough, which is worth knowing before you list six and assume you have built a quorum - you have built six independent keys, not a committee. Turn on "prevent self-review" so the person who triggered the run cannot wave their own change through.
Wait timer. A delay between one and 43,200 minutes before the job proceeds. Useful as a cooling-off period on a production deploy, and not billed, because nothing is executing while it waits.
Deployment branches and tags. A pattern list of what is allowed to reach this environment at all. This is the one people skip and it is the one that closes the obvious hole.
Custom protection rules. An external service that has to say yes. Real, and more machinery than most of us need.
The approval belongs to the client
Here is the arrangement I now use, and it is the single most useful thing in this article. The required reviewer on the production environment is not me and it is not anyone at my company. It is the client's IT lead.
Say that out loud in a procurement meeting and watch what it does. "No code reaches your server unless a named person at your organisation approves it in GitHub, and there is a permanent record of who approved what and when." That is an answer to a question their auditor has, and you have given it before it was asked.
It also solves a problem that has nothing to do with security. It ends the argument about deployment windows. You tag whenever you like; it deploys when they are ready for it. Nobody has to be online at the same time, which matters when their finance team will not accept a migration during month-end close.
Tag protection, and the hole people leave open
The environment gate protects the job. It does not, by itself, control what code the job runs. If your workflow triggers on tags and someone can push any tag, the gate is guarding a door in a wall with no other bricks in it.
Two settings close that. On the environment, restrict deployment branches and tags to a specific pattern, so the production environment can only ever be reached by a run whose ref matches v*. Patterns are configured separately for branches and tags, so if you only fill in the branch box you have not restricted tags at all - a common and quiet mistake.
Then, on the repository, protect main with a ruleset requiring a pull request and a passing status check, and add a tag ruleset so only maintainers can create v* tags. Now the chain is: code enters main only through review, a release tag can only be created by a maintainer, and the tag only deploys after the client approves. Three independent gates, none of which depends on trusting the others.
Practical rules for the secrets themselves
A short list, all of it learned the hard way.
Do not put the database password in CI at all. This is the biggest one. In the runner-based deployment this series builds towards, the deploy script runs on the client's own server as the frappe user, and the site's credentials are already in sites/dev.client.local/site_config.json where the framework expects them. The workflow never needs them. If you find yourself adding a DB_PASSWORD secret, stop and ask what is running in the wrong place - usually it means a step that belongs in the client-owned deploy script has crept into the workflow file.
Name secrets so a leak is diagnosable. CLIENT_A_PROD_NOTIFY_TOKEN beats TOKEN. When something turns up in a paste bin, you want to know in one second which system to rotate.
Never interpolate a secret into a shell command. Write this:
- name: Notify the client
env:
WEBHOOK: ${{ secrets.CLIENT_A_DEPLOY_WEBHOOK }}
run: curl -sS -X POST -d "release=${GITHUB_REF_NAME}" "$WEBHOOK"
Not curl ... "${{ secrets.CLIENT_A_DEPLOY_WEBHOOK }}". The ${{ }} form is substituted into the script text before bash sees it, so the value lands in the process arguments, in any shell trace, and in the error message if the command fails. Passing through env: keeps it out of the command line.
What this does not give you
Be clear-eyed about the limits, because a security team will find them.
The gate protects the client's server from an unapproved release. It does not protect them from an approved one. If the reviewer clicks approve on a run whose workflow file was quietly changed in the same commit range, the gate has done its job and the outcome is still bad. Approval is only as good as what the approver can see, which is why tag protection and branch protection are not optional extras next to it.
It also does not protect against the runner itself being the target. The environment gate decides whether a job runs. Once it runs, it runs with whatever privileges the runner has on that machine, and that is a separate problem with separate mitigations, covered in the article on why push access is code execution.
And none of it applies if you have not yet understood why the job executes on the client's machine at all. That is what a runner actually is, which is the prerequisite for this one. If the YAML in this article looked unfamiliar, the workflow, job and step walkthrough covers the syntax.
Frequently asked questions
- Can a collaborator read a repository secret?
- Not in the settings UI, but yes in practice. GitHub's own guidance states that any user with write access to your repository has read access to all secrets configured in it, because they can push a workflow that prints the secret somewhere they control. Treat write access and secret access as the same permission.
- Does GitHub not mask secrets in the logs?
- It masks exact matches, and GitHub says automatic redaction is not guaranteed. Base64 the value, split it across two echo calls, or pipe it through a command that reformats it, and the mask no longer matches. Redaction protects against accidents, not against someone trying.
- How long does a job wait for a required reviewer?
- Up to 30 days, then it fails. That limit is not configurable. Waiting time is not billed, since nothing is executing on a runner while the job sits in the Waiting state.
- Can I make the client the only person who can approve a deploy?
- Yes, and you should. Add their IT lead as the sole required reviewer on the production environment and enable prevent self-review. From then on no release reaches their server without a named person at their organisation clicking approve, and that fact is worth more in a security review than any assurance you could write.