The last chapter got you a green check mark before merge. This one is about what happens after merge: turning a passing build into bytes on a server that has no idea what a GitHub Action is. Shared hosting doesn't speak kubectl, doesn't run a webhook receiver, doesn't have a concept of a deployment. It has FTP, a filesystem, and a cron table. Everything in this chapter is about building a pipeline that respects that constraint without becoming a manual chore every time you ship.
Captainrandom.co.uk actually runs two deploy pipelines against the same host: one for the static Next.js export, one for a small PHP backend that handles newsletter subscriptions. The FTP mechanics of a single deploy workflow, the action, the four secrets, the artefact handoff, are covered in the prerequisite chapter on the FTP-Deploy-Action. This chapter is about what happens once you have two of those workflows pointed at the same server and they can't be allowed to interfere with each other.
One repo, two deploy targets
The static site and the backend live in the same repository but they are different surfaces with different blast radii. The static site is stateless: if a bad deploy ships, the fix is another deploy. The backend touches a live MySQL table of subscriber emails and a rate-limited public endpoint. Rebuilding and re-shipping the whole backend every time a blog post changes would be wasteful and, worse, it would widen the backend's deploy frequency (and therefore its risk surface) for no reason connected to the backend at all.
So this repo runs deploy.yml for the static site and a second, sibling workflow, deploy-backend.yml, for everything under cpanel-backend/. Two workflow files, two triggers, two sets of FTP steps, sharing the three credential secrets for the same FTP account (the fourth, the destination-directory secret, only exists where a single destination makes it worth having, more on that below). The architectural question a solo maintainer has to answer here isn't "how do I FTP a folder", it's "how do I stop unrelated changes from triggering deploys they have nothing to do with."
Path filters: only run when the target actually changed
A push to main that only touches an MDX post under src/content/posts/ has no business re-deploying the PHP backend. GitHub Actions solves this with a paths filter on the trigger. Per the documented trigger syntax, on: push: paths: accepts glob patterns, and the workflow only runs when at least one changed file matches one of them:
on:
push:
branches: [main]
paths:
- 'cpanel-backend/**'
- '.github/workflows/deploy-backend.yml'
workflow_dispatch:
Two entries matter here for different reasons. cpanel-backend/** is the obvious one: any change under that directory triggers a backend deploy. The second entry, the workflow file matching itself, is easy to forget and expensive to forget. If you edit deploy-backend.yml to fix a bug in the pipeline, and the filter only watches cpanel-backend/**, that edit won't trigger a run to prove the fix works. You'd merge a workflow change with zero verification that it deploys correctly. Filtering the workflow file into its own trigger closes that gap.
The static site's deploy.yml has no paths filter at all, because almost everything in the repo (components, MDX content, globals.css) legitimately affects the static export. The absence of a filter is a decision, not an oversight: filter only the workflow whose target is a genuine subset of the repo.
Order is the contract: four FTP steps, one dependency chain
The backend workflow doesn't do one FTP sync, it does four, and the order between them is not cosmetic. The public-facing PHP entry points do a plain require_once against library code and template files. If the public files land on the host before the library they depend on, any request that hits the site in that window gets a fatal PHP error instead of a 500 with a nice message. The fix is to sequence the four syncs so nothing that depends on another piece ever lands first:
jobs:
deploy-backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy lib/
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./cpanel-backend/lib/
server-dir: captainrandom-newsletter/lib/
- name: Deploy templates/
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./cpanel-backend/templates/
server-dir: captainrandom-newsletter/templates/
- name: Deploy cron/
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./cpanel-backend/cron/
server-dir: captainrandom-newsletter/cron/
- name: Deploy public/ (last)
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./cpanel-backend/public/
server-dir: public_html/api/
include-hidden-files: true
Steps in a single job run sequentially by default, no needs: graph required, which is exactly what a strict dependency chain wants. lib/ and templates/ go first because nothing else depends on them and they depend on nothing. cron/ goes next, it's invoked on a schedule rather than per-request so its timing is the least urgent. public/ goes last, deliberately, because it's the one directory a live HTTP request can hit mid-deploy. Landing it last means that for the whole window while the other three sync, the public entry points on the host are still running against the previous version of the library, which is internally consistent, rather than the new public code running against a half-updated library, which is not.
include-hidden-files: true reappears on the public/ sync for the same reason it appears on the static site's artefact upload: the PHP backend ships an .htaccess alongside the public entry points, and it is a dotfile subject to the identical stripping behaviour the prerequisite chapter covers in depth. The fix doesn't change between the two pipelines. The audit for it doesn't either: gh run view <id> --log | grep include-hidden-files on any workflow that syncs a directory containing a dotfile.
Three shared secrets, one that isn't
Both workflows deploy to the same FTP account on the same host, but they don't reuse the identical secret set. deploy-backend.yml reads FTP_SERVER, FTP_USERNAME, and FTP_PASSWORD, exactly the three credentials, and stops there: each of its four FTP steps hardcodes its own server-dir as a literal (captainrandom-newsletter/lib/, .../templates/, .../cron/, public_html/api/). FTP_SERVER_DIR is never referenced in that workflow at all. It's consumed by exactly one place: deploy.yml, the static site's workflow, which has a single destination and so takes it as server-dir: ${{ secrets.FTP_SERVER_DIR }}.
That split isn't an oversight, it follows from the shape of each job. The static site syncs one artefact to one directory, so a single secret holding that one path is the right level of indirection: rotate the destination without touching YAML. The backend syncs four different local directories to four different remote directories in one job, and those four destinations are fixed by the backend's own internal structure, not by anything that varies per environment or needs to be hidden. A single FTP_SERVER_DIR secret can't hold four values, and inventing four backend-specific directory secrets to avoid four string literals would be indirection with no payoff: the paths aren't sensitive, and hardcoding them next to the sync step that uses each one is more legible than resolving them through a name defined somewhere else. Per the GitHub secrets guide, a secret is referenced through the secrets context, ${{ secrets.FTP_SERVER }}, and GitHub redacts exact matches of the value from the run log automatically. Nothing about having two workflows changes that mechanism; the three credentials the backend does reuse are repository-scoped, not workflow-scoped, so adding a second deploy pipeline cost zero new secret configuration for those three.
What it does cost is discipline about not transforming those values. The same guide's warning about secrets over 48 KB needing an encrypt-and-decrypt workaround comes with a line worth internalising even though an FTP password is nowhere near that size: "GitHub does not redact secrets that are printed in logs" once they've been through any transformation. Never Base64-encode a secret for a debug echo, never splice one into a string you then log, in either workflow. Masking only holds for the literal value GitHub was told to watch for.
Two failure notifiers that both fail open
Each workflow ends with a Telegram notify step gated on if: failure(), and both are written the same defensive way: check that the two Telegram secrets are set, and if either is missing, echo a message and exit 0 rather than letting a missing notifier secret turn into a second, unrelated workflow failure. That fail-open behaviour matters more here than it would in a single-pipeline repo, because now there are two independent workflows that can fail for two independent reasons, and a maintainer checking a phone wants "the backend deploy failed" and "the static deploy failed" to be two distinguishable messages, not one generic "something broke" alert repeated twice with no way to tell which pipeline needs attention.
The two notify steps are deliberately not shared as a reusable workflow or composite action. That's a valid simplification to reach for once you've watched them drift in real use, but it's also one more moving part to maintain, and for two call sites duplicating a dozen lines of YAML is cheaper than the indirection of a shared action.
Recovery levers when a deploy doesn't land
A shared host over FTP times out sometimes. Port 21 hiccups, the host is momentarily busy, nothing about your code is wrong. Two commands cover recovery, and which one applies depends entirely on whether the push event that should have triggered the deploy still exists to be re-run.
If the failed run is recent, gh run rerun <run-id> --failed reruns "only failed jobs, including dependencies," against the original event, no new commit needed:
gh run rerun 123456789 --failed
If the run has aged out, or you want to force a redeploy without a code change at all, such as after rotating the FTP password, gh workflow run <workflow> --ref <branch> dispatches the workflow_dispatch trigger directly:
gh workflow run deploy-backend.yml -r main
Both levers only work because both workflows declare workflow_dispatch in their on: block. Add it once, on every deploy workflow you write, even the ones you think will never need a manual re-drive. The one time a shared host goes quiet for thirty-six hours and every deploy has to happen by hand is not the time to discover a workflow can't be manually triggered.
What you have now
Two deploy workflows sharing one FTP account, kept from colliding by path filters that scope each to its own surface, a hard dependency order inside the backend pipeline that respects what its own PHP requires from what, the same hidden-file fix applied wherever a dotfile crosses an FTP boundary, and two independent, fail-open failure notifiers so a broken pipeline announces itself instead of failing silently. The next chapter moves from deploys triggered by a push to work that GitHub runs on a clock, with no human push involved at all.
Add a path-filtered sibling workflow, then prove both recovery levers on it
If your repo has more than one deployable surface (a frontend and an API, a site and a set of serverless functions, a monorepo package), split its deploy into two workflows the way this chapter does, scoped so each only runs for its own changes. If you genuinely have only one surface, simulate the split by carving out a subdirectory (docs/, scripts/) and giving it its own no-op workflow, purely to prove the filter works. Once it exists, exercise both recovery commands against it so they're muscle memory before a real failure, not something you look up mid-incident.
Expected behaviour
- A second workflow file exists with an on.push.paths list scoping it to its own directory plus the workflow file's own path, and workflow_dispatch present so it can be manually re-driven
- A commit that only touches the other surface produces no run of this workflow in the Actions tab, while a commit touching the scoped directory does
- gh workflow run <workflow>.yml -r main dispatches a run with no new commit, and gh run list --workflow=<workflow>.yml locates its ID
- gh run rerun <run-id> --failed is issued against that run to confirm the command's syntax and permissions work before you ever need it under pressure
PROVE IT Run gh run list --workflow=<your-new-workflow>.yml --limit 5 and show a run history that only contains commits touching the scoped paths, including the manually dispatched run and its rerun.
A repo has a static site and a PHP backend deploying to the same host from two sibling workflows. A commit only changes an MDX blog post. What should happen?
In the backend pipeline's four FTP syncs, which directory is deployed last, and why: because it's the one a live HTTP request can hit mid-deploy?
Your backend deploy workflow fails at 2am with an FTP timeout. By the time you notice, at 9am, six unrelated commits have landed on main. Walk through which recovery command you reach for and why, and what you'd check first if the workflow simply never ran at all.
Show answer
Because six unrelated commits have since landed, the original failed run's push event is stale to rerun in place if you want the very latest code deployed; the cleaner lever is gh workflow run deploy-backend.yml -r main, which dispatches via workflow_dispatch against the current state of main rather than replaying an old event. gh run rerun <id> --failed would still work and would only rerun the failed jobs, but it would deploy the code as of the original failed commit, not the six commits that followed. If the workflow never ran at all rather than failing, the first thing to check is the paths filter: confirm with git diff --name-only whether any of the six commits actually touched cpanel-backend/** or the workflow file itself, since a filter that never matched produces silence, not a failure to investigate.