A Visual Guide

From FTP
to Releases

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.
TODAY Laptop FTP Live server ← and hope AFTER THIS GUIDE Commit Tests Release QA approve Production
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.

1 Commit A change on a branch. Cheap. Disposable. OWNER: developer 2 Artifact Tested, packaged, install-ready. Built once. OWNER: CI 3 Release A named, immutable version + notes. OWNER: you v1.4.0 4 Deploy- ments AUTOMATED, GATED qa testers work here staging production-like production live · approval gate ONE release deployed many times WHY THIS MATTERS A tester cannot sign off on “the current state of the server” — it changes under them. They can sign off on v1.4.0, because it is a fixed thing with a name.
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

TodayAfter
“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 uploadedServer state is a version number you can look up
Rollback = find the old files, re-uploadRollback = flip a symlink, about one second
You are the only one who knows what’s liveThe 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.

WRONG — build per environment Each server pulls and builds for itself. Three builds, three results. git repo main composer install Mon 09:00 · guzzle 7.8.1 composer install Wed 16:00 · guzzle 7.8.2 composer install Fri 11:00 · guzzle 7.9.0 qa staging production Three different applications QA tested one of them. Nobody tested the one customers are using. RIGHT — build once, in CI One tarball. Every environment installs that exact file. tag v1.4.0 CI builds ONCE app-1.4.0.tar.gz sha256 3f9a1c... GitHub Release qa 3f9a1c staging 3f9a1c production 3f9a1c identical checksum = identical app
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.

Smoke 3–5 URLs Integration real test database runs on every PR Unit pure logic — no DB, no network, no filesystem whole suite under 5 seconds write most of your tests here slower, fewer faster, many WHEN EACH ONE RUNS composer test:unit on every save, by every developer composer ci before pushing — and in GitHub Actions Smoke test step automatically, right after every deploy THE POINT OF composer ci Developers and CI run the identical command. When CI goes red, it can be reproduced locally in one step.
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.

feature/ export main hotfix/ 1.4.1 3 commits, 2 days PR + CI green then squash-merge v1.4.0 v1.5.0 urgent VAT fix v1.4.1 merged back Hotfixes branch from the TAG, never from main. That ships the urgent fix without also shipping whatever half-finished work is sitting on main. Then merge it back.
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

v1 . 4 . 2 MAJOR Breaking change non-reversible migration, changed API contract, config that must be updated MINOR New features backwards compatible — nothing existing breaks PATCH Bug fixes only no new behaviour, safe to deploy quickly
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.

github.com/acme/webapp/releases/tag/v1.4.0 v1.4.0 tagged 4 Aug 2026 · commit 3f9a1c2 Latest release 1 Release notes auto-generated from merged PR titles, grouped by label ⚠ Breaking Changes • Invoice numbering format changed #142 🚀 Features • Add CSV export to invoices #138 🐝 Bug Fixes • Fix VAT rounding on credit notes #140 This is what QA reads to know what to test. Controlled by .github/release.yml 2 Assets — your build artifact lives here this file is the “build once” store from Chapter 2 app-1.4.0.tar.gz 8.2 MB app-1.4.0.tar.gz.sha256 3 Pre-release flag marks RCs as not-for-production Pre-release
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.

dev every merge to main catch integration breaks fast churns constantly — not for testers no gate Deploys within ~2 minutes of merging. Fully automatic. qa you dispatch a release to it testers work here stable — nobody redeploys under them optional QA lead approval You pick the version in the Actions tab. Testers get told. staging release candidate, after QA sign-off production-like config and data shape final rehearsal for the real thing approval Job pauses in “Waiting” until someone clicks Approve. production approved release only live customers tag-restricted: only v* may deploy here required reviewers + 10 min wait timer The wait timer gives someone a window to cancel. Cheap insurance. ENVIRONMENT GATE WHAT IT FEELS LIKE
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

ONE workflow file deploy.yml environment: ${{ inputs.env }} runs-on: [self-hosted, ${{ inputs.env }}] APP_ROOT: ${{ vars.APP_ROOT }} identical process everywhere resolves to qa APP_ROOT=/var/www/app DB_HOST=qa-db.internal staging APP_ROOT=/var/www/app DB_HOST=stg-db.internal production APP_ROOT=/var/www/app DB_HOST=db.internal WHAT YOU GET FREE Scoped secrets per environment Required-reviewer approval Branch / tag restrictions Wait timers Deployment history page When QA asks “which build am I testing?”, the answer is on a web page — not in your head.
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.

GitHub-hosted runner — connection goes INWARD PUBLIC INTERNET GitHub runner ubuntu-latest holds your SSH / FTP key FIREWALL hole punched YOUR NETWORK web server port 22 / 21 exposed reachable from outside WHAT THIS COSTS YOU • An inbound firewall rule IT has to approve and maintain • GitHub’s IP ranges allowlisted — and they change • Long-lived SSH keys stored at GitHub • A permanently open door on a production host Self-hosted runner — connection goes OUTWARD PUBLIC INTERNET GitHub holds the job queue never initiates a connection to your network FIREWALL no holes needed HTTPS 443 out “any jobs for me?” job + artifact come back down the same connection YOUR NETWORK web server runner agent already ON the target box deploy = a local file copy WHAT YOU NEED FROM IT • Outbound HTTPS to github.com, api.github.com,   *.actions.githubusercontent.com, codeload.github.com AND NOTHING ELSE No inbound rules. No SSH keys at GitHub. No FTP credentials. No VPN.
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

runs-on: [self-hosted, linux, deploy, qa] matches web-qa-01 …,deploy,qa web-stg-01 …,deploy,staging web-prod-01 …,deploy,production web-prod-02 …,deploy,production Get a label wrong and you deploy to the wrong environment. Treat them as config.
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.

ON-SERVER LAYOUT /var/www/myapp/ releases/ 1.3.9/ older, kept for rollback 1.4.0/ previous 1.4.1/ just unpacked shared/ survives every deploy .env config — never in the artifact uploads/ storage/ backups/ current releases/1.4.1 a symlink — this is what nginx/Apache serves DocumentRoot /var/www/myapp/current/public THE SWITCHOVER 1 Unpack, dark current 1.4.0 1.4.1 New code on disk but nothing serves it. Migrations + cache warm happen here. 2 Flip — atomic rename current 1.4.0 1.4.1 ln -sfn releases/1.4.1 current.tmp mv -Tf current.tmp current ← instant 3 Reload PHP-FPM — mandatory sudo systemctl reload php8.3-fpm PHP-FPM caches the resolved realpath and opcache entries. Skip this and the OLD code Rollback = flip it back. mv -Tf → releases/1.4.0 + reload ≈ 1 second
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 shell ln -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

Backwards compatible Old code and new code can both run against the migrated schema. So rollback is safe. RELEASE 1 add nullable column vat_rate_v2 RELEASE 2 write to both columns, backfill old rows RELEASE 3 stop using the old column, then drop it Destructive Once it runs, the old code cannot work. The symlink rollback will not save you. • DROP COLUMN in the same release that   stops using it • RENAME COLUMN • NOT NULL added with no default = a MAJOR version + a written rollback plan
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.

WHAT A TESTER NEEDS FROM YOU 1 A stable environment nobody redeploys under them dev churns constantly; qa changes only when someone deliberately dispatches a build. Break this and QA stops trusting their own results. 2 To know exactly what they’re testing Expose the version in the app. Every bug report includes it. This alone kills a whole class of wasted investigation. GET /version → {"version":"1.4.0","commit":"3f9a1c2"} written into the artifact at build time 3 Release notes that say what to test The auto-generated PR list, plus a short “focus areas” paragraph you write by hand. Two sentences is enough. 4 A defined way to report, and a defined way to sign off GitHub Issues with a bug template is enough to start. Sign-off = approving the production deployment as a required reviewer. 5 Seeded, non-production test data Never a copy of live data with real customer information — that becomes a GDPR problem the moment QA is a separate team. Write a seed script.
Figure 13 — The five things to have ready before you invite testers in.

A rhythm to propose

merges continuous → dev tag rc.1 batch ready → qa notify testers rc.2, rc.3 bugs found fix on main, re-tag, redeploy sign-off QA happy tag v1.5.0 same commit as rc live staging → production with approval
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.

1 WEEK Test foundation composer scripts · phpunit.xml · 10 unit tests on your most bug-prone logic · add ci.yml · protect main YOU CAN NOW: no longer merge broken code 2 Server layout extract config to .env · restructure one server to releases/current · write deploy.sh · run it by hand over SSH YOU CAN NOW: deploy atomically and roll back — still hands-on 3 Automate QA self-hosted runner on the QA box · add release.yml + deploy.yml · create the qa environment YOU CAN NOW: push-button deploys — FTP is unnecessary for QA 4 Production & retire FTP runner on production · production environment with required reviewers · /version endpoint · smoke tests YOU CAN NOW: rotate the FTP credentials out of existence Then: bring QA in and agree the sign-off protocol · integration tests · PHPStan at a low level, ratcheting upward
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.ymltests on push/PR
.github/workflows/release.ymltag → build → Release
.github/workflows/deploy.ymlRelease → environment
.github/release.ymlnotes grouping
deploy/deploy.shon-server deploy
phpunit.xmlunit/integration split

GitHub settings to change

Branches → protect mainrequire CI + PR
Environments → productionrequired reviewers
Environments → productionrestrict to tag v*
Actions → Runnersregister + label
Actions → Generalapproval for outside PRs

Debugging a deploy that “did nothing”

  1. Did PHP-FPM get reloaded? Most likely cause.
  2. Does readlink -f current point at the new version?
  3. Does /version report the new number?
  4. Is DocumentRoot pointing through current, or at a release directory directly?
  5. 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 things 1. 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.