GitHub Actions, environments and QA handoff for a PHP team —
moving from one developer with an FTP client to a shared codebase
with testers, gated deploys and servers behind a firewall.
You’ll be able to
Prove the code on production is the exact code QA approved.
Without
Opening a single inbound firewall port, or storing an SSH key at GitHub.
And
Roll back a bad deploy in about one second.
Prepared for Steve Rees · August 2026 · PHP, self-hosted runners, firewalled web servers
Nine short chapters. Every diagram is meant to be readable on its own — skim the figures first if you like.
Chapter 1
Four concepts, not one
Right now you have a single idea — “the files on the server”. Splitting it into four is what makes QA possible at all.
Everything in this guide follows from one shift in thinking. A commit is not a release. A release is not a deployment. Once those are separate things with separate names, you can hand one of them to a tester and talk about it precisely.
Figure 1 — The four concepts. Read left to right: developers make commits, CI turns one commit into one artifact, you name it as a release, and that single release is then deployed to several environments.
What changes for you, in practice
Today
After
“I’ll FTP the fix up now”
“That’s in v1.4.3, deployed to QA at 14:20”
Server state is whatever you last uploaded
Server state is a version number you can look up
Rollback = find the old files, re-upload
Rollback = flip a symlink, about one second
You are the only one who knows what’s live
The repo homepage shows it to everyone
Chapter 2
Build once, deploy many
The single most important rule in this document. Break it and QA’s sign-off means nothing.
The artifact QA tested must be byte-identical to the artifact that reaches production. If each environment independently runs git pull and composer install, you have built the app three separate times, on three separate days, possibly with three different dependency versions. QA approved the first one. Production runs the third.
Figure 2 — The same release, verified by checksum, on every environment. This is the whole reason the pipeline exists.
Practical test
Ask yourself: could I prove the code on production is the code QA approved? With FTP the honest answer is no.
With a checksummed artifact it is a one-line sha256sum -c in the deploy log.
Chapter 3
Local tests come first
This is the genuine prerequisite — and probably a bigger job than any of the YAML.
Without local tests, CI only tells you late what you could have known early, and a second developer will break things they cannot see. Start at the bottom of this pyramid and mostly stay there.
Figure 3 — The test pyramid, and where each layer fires in your workflow.
One entry point: composer scripts
// composer.json — every developer and CI run these exact commands"scripts": {
"test": "phpunit",
"test:unit": "phpunit --testsuite=unit",
"test:integration": "phpunit --testsuite=integration",
"lint": "php-cs-fixer fix --dry-run --diff",
"analyse": "phpstan analyse src --level=5",
"ci": ["@lint", "@analyse", "@test"]
}
The real blocker, honestly
If your PHP currently mixes business logic, SQL and echo in the same files, unit tests are not yet possible —
and no amount of CI config fixes that. The minimum change: move decision-making logic into plain classes in src/
that take arguments and return values, with no $_POST, no echo, no direct database calls.
Those are testable in milliseconds. Do this for your most bug-prone area first; don't attempt the whole codebase.
Don’t chase a coverage percentage. Ten good unit tests around your invoice calculations are worth more than 60% coverage of getters and setters.
Chapter 4
Branches, versions and releases
Keep the branching model boring. Put the effort into version numbers, because that’s what QA will talk to you in.
Trunk-based, with short-lived branches
You do not need GitFlow. main is always releasable and protected — no direct pushes, PR plus passing CI required. Feature branches live hours or days, not weeks. Releasing means tagging main.
Figure 4 — Trunk-based flow. Note the dashed hotfix line starts at v1.4.0, not at the tip of main.
Semantic versioning, and why you care
Figure 5 — Semantic versioning. The practical value: QA can say “broken in 1.4.2, fixed in 1.4.3” instead of “broken on Tuesday”.
Release candidates get a suffix: v1.5.0-rc.1. Anything with a hyphen is marked as a pre-release on GitHub, so it’s unmistakably not for production.
What a GitHub Release actually is
A tag, plus three things. That’s all — but each of the three does real work for you.
Figure 6 — Anatomy of a GitHub Release. Two useful side effects: publishing one is an event you can trigger workflows from, and the whole thing is scriptable with gh release create.
# cutting a release is two commands
git tag -a v1.4.0 -m "Invoice CSV export"
git push origin v1.4.0 # ← the tag push is what starts the pipeline
Chapter 5
Environments — the part that enables QA
A GitHub Environment is a named deployment target with its own secrets and its own rules. This is your change-control process, for free.
Create four. The important column is the third one — the gate is what stops a deploy dead until a human agrees.
Figure 7 — Four environments, increasing in ceremony. Set required reviewers on production on day one — it is the single highest-value setting in this whole guide.
Why environments and not just four separate workflows
Figure 8 — Same YAML, different values. Because the process is one file, you can be certain the QA deploy and the production deploy did the same thing.
One line does all of it
Adding environment: production to a job is what triggers approval, applies that environment’s secrets,
and records the deployment on the repo homepage. Set it up at Settings → Environments.
Chapter 6
Reaching servers behind the firewall
This is the chapter that replaces FTP. The whole insight is about which direction the connection travels.
GitHub’s hosted runners live on the public internet. To deploy from one, it has to connect inbound to your server — open ports, IP allowlists that change, credentials stored at GitHub. A self-hosted runner inverts this completely.
Figure 9 — The key diagram in this guide. The runner polls outward; GitHub never dials in. This is the argument that gets IT on side.
Installing one, on each web server
# Token comes from: repo Settings → Actions → Runners → New self-hosted runner# Run as a dedicated NON-ROOT user, e.g. github-runner
mkdir ~/actions-runner && cd ~/actions-runner
curl -o runner.tar.gz -L https://github.com/actions/runner/releases/latest/download/actions-runner-linux-x64.tar.gz
tar xzf runner.tar.gz
./config.sh \
--url https://github.com/YOUR-ORG/YOUR-REPO \
--token AAAA... \
--name web-qa-01 \
--labels self-hosted,linux,deploy,qa# ← labels are load-bearing
--unattended
sudo ./svc.sh install github-runner && sudo ./svc.sh start
Labels are how jobs find the right box
Figure 10 — Label-based routing. runs-on is a set-match: the job lands only on a runner carrying all of those labels.
Runner security — read this before IT does
Self-hosted runners are the sharp edge of Actions. Never put one on a public repository — anyone could open a PR
whose workflow runs arbitrary code on your web server. On a private work repo the risk is lower but real. The mitigations that matter:
Run as an unprivileged user. Grant one narrow sudoers rule for systemctl reload php8.3-fpm and nothing more.
Only deploy jobs run on it. Build and test on GitHub-hosted runners; the self-hosted runner should only ever unpack an already-built artifact. This keeps untrusted PR code off production hosts entirely.
Restrict the runner group to selected repositories and selected workflows.
Require approval for workflow runs from outside/first-time contributors.
Never combine pull_request_target with a checkout of the PR head on a self-hosted runner — that is a known full compromise.
Keep the runner updated. GitHub enforces a minimum version (v2.329.0+); older ones get blocked and carry a runner-escape vulnerability.
Runners are persistent — files from a previous job survive. Clear your working directory explicitly.
Chapter 7
Atomic deploys — stop overwriting files
This is the specific thing that makes FTP deploys frightening: for a few seconds the site is half old and half new.
Instead of writing over the live files, unpack each release into its own timestamped directory and point a symlink at it. Switchover becomes a single instant rename. Rollback becomes the same rename, backwards.
Figure 11 — Release directories plus a symlink. The gotcha in frame 3 is the number-one reason symlink deploys appear to “do nothing”.
Two traps in one line of shellln -sfn on a symlink that already points at a directory creates the new link inside it rather than replacing it.
That’s why the script writes current.tmp and then mv -Tf it over the top — the rename is what’s atomic.
And if you forget the PHP-FPM reload, the deploy will look successful in the logs while the old code keeps serving.
Database migrations: where “just roll back” stops being true
Figure 12 — Split destructive schema changes across releases. Run migrations before the symlink flip, so a failure aborts while the old code is still serving.
Chapter 8
Handing over to QA
The tooling is necessary but not sufficient. This part is a human protocol.
Figure 13 — The five things to have ready before you invite testers in.
A rhythm to propose
Figure 14 — A release cycle. Note the final tag sits on the same commit as the approved release candidate — nothing new sneaks in after sign-off.
Secrets, config, and what must NOT go in the artifact
No .env in the artifact. Config lives in shared/.env on each server. The artifact must be environment-agnostic — that’s the whole point of building once.
Use Environment secrets, not repo secrets, so production credentials aren’t readable by a workflow targeting QA.
Rotate the FTP credentials out of existence once deploys are automated. Every remaining manual path is a way for servers to drift out of sync with git — the exact thing you’re eliminating.
Add .github/CODEOWNERS so changes to workflows and deploy scripts need your review. Workflow files are effectively production access; treat them like it.
Chapter 9
A realistic order to do this in
Don’t build the whole pipeline at once. Each step below is independently useful, and each one ends with something you couldn’t do before.
Figure 15 — Four weeks, four capabilities. If you only get through week one, you have still meaningfully changed how the team works.
Reference
Cheat sheet
The commands and file locations you’ll want in front of you.
Daily loop
# before you push
composer ci
# just the fast tests
composer test:unit
Cutting a release
git tag -a v1.4.0 -m "CSV export"
git push origin v1.4.0
# release candidate
git tag -a v1.5.0-rc.1 -m "RC1"
git push origin v1.5.0-rc.1
Hotfix from a tag
git checkout -b hotfix/1.4.1 v1.4.0
# ...fix, commit...
git tag -a v1.4.1 -m "VAT fix"
git push origin v1.4.1
git checkout main && git merge hotfix/1.4.1
Deploy by hand
gh workflow run deploy.yml \
-f environment=qa \
-f version=1.4.0
# or: Actions tab → Deploy → Run workflow
Where things live
.github/workflows/ci.yml
tests on push/PR
.github/workflows/release.yml
tag → build → Release
.github/workflows/deploy.yml
Release → environment
.github/release.yml
notes grouping
deploy/deploy.sh
on-server deploy
phpunit.xml
unit/integration split
GitHub settings to change
Branches → protect main
require CI + PR
Environments → production
required reviewers
Environments → production
restrict to tag v*
Actions → Runners
register + label
Actions → General
approval for outside PRs
Debugging a deploy that “did nothing”
Did PHP-FPM get reloaded? Most likely cause.
Does readlink -f current point at the new version?
Does /version report the new number?
Is DocumentRoot pointing through current, or at a release directory directly?
Did the job land on the runner you expected? Check the labels.
Worth reading up on next
Reusable workflows (workflow_call) — share one deploy job across repos
OIDC — short-lived credentials, no stored secrets, if you move to cloud hosting
Ephemeral / container runners — better isolation than a persistent runner
Blue/green — the symlink pattern is a stepping stone toward it
If you remember only three things1. Build the artifact once and deploy that same file everywhere — otherwise QA’s sign-off is meaningless. 2. A self-hosted runner dials out, so no inbound firewall rules and no stored credentials. 3.environment: production plus required reviewers is your entire change-control process, in one line.
Accompanying files: ci.yml, release.yml, deploy.yml, deploy.sh, phpunit.xml, release.yml.changelog — all commented and ready to adapt.