Chapter 7 of 8. Prerequisite: Chapter 5 — you should already have the
public/.htaccessthat ships intoout/.htaccess. This chapter is about getting that file onto the host without it vanishing on the way.
Everything up to here has been about producing a correct out/ folder. This chapter is about moving it. The failure mode is specific and it is nasty. The deploy runs green, the site loads, and then one day a deep link 404s with the host's default error page instead of your styled one. Nobody connects it to the deploy, because the deploy "worked." What actually happened is your .htaccess never made the trip. It is a dot-prefixed hidden file, and hidden files get skipped by default at more than one stage of a GitHub Actions FTP pipeline.
I hit exactly this on captainrandom.co.uk. This chapter is the workflow that fixes it, built the way the real one is built.
The shape: one build job, one deploy job
The deploy is a single workflow file, .github/workflows/deploy.yml, with two jobs. Per the GitHub Actions workflow syntax, a run is made up of jobs that run in parallel by default. Here the second job declares needs: build, which forces them to run in order. The split matters. The build job runs on every push and every pull request. It typechecks, tests, and runs next build, then uploads the out/ folder as an artefact. That is your CI gate. The deploy job runs only on a push to main (or a manual re-run). It downloads that same artefact and FTPs it to cPanel.
Building on PRs but only deploying from main means a PR gets the full build check without ever touching production. Here is the trigger block and the first job:
name: CI / Deploy → cPanel
on:
push:
branches: [main]
pull_request:
branches: [main]
# Manual trigger - re-deploy without a code change, e.g. after an
# FTP password rotation, or to re-drive a transient FTP failure.
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Typecheck & Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run typecheck
- run: npm test
- name: Build (static export)
run: npm run build
env:
NODE_ENV: production
The concurrency block cancels an in-flight run when you push again to the same ref, so a rapid double-push doesn't race two deploys onto the host.
Where hidden files get stripped: the Next.js artefact upload
Here is the part almost every tutorial gets wrong, because most tutorials build and FTP in one job. When you split build from deploy (which you want, so PRs can build without deploying) the two jobs hand off through an artefact. And actions/upload-artifact@v4 excludes hidden files by default. A dot-prefixed .htaccess sitting in out/ is a hidden file. So it never makes it into the artefact, the deploy job downloads an artefact that never contained it, and the file never reaches the host — with no error to point at.
The fix is one line, and it is on the upload step, not the FTP step:
- name: Upload build artefact
uses: actions/upload-artifact@v4
with:
name: static-out
path: out/
retention-days: 1
# Required so out/.htaccess (and any future dotfile) reaches the
# deploy job. Default is false, which drops dotfiles from the
# artefact and leaves Apache without its ErrorDocument.
include-hidden-files: true
include-hidden-files: true is an input of actions/upload-artifact. Note that carefully, because the naming causes a real confusion I want to head off: the FTP action itself has no such input. If you go looking to add include-hidden-files to the SamKirkland/FTP-Deploy-Action step, you will not find it. The action's README settings table lists server, username, password, local-dir, server-dir, exclude, dangerous-clean-slate and a handful of others, with no hidden-files toggle. The FTP action ships dotfiles fine on its own. The stripping happens earlier, at the artefact boundary. Put the flag where the loss happens.
The deploy job and the FTP action
The second job downloads the artefact and runs the FTP push. The if: condition is the guard that keeps PRs from deploying:
deploy:
name: Deploy to cPanel
needs: build
runs-on: ubuntu-latest
if: >-
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch')
steps:
- name: Download build artefact
uses: actions/download-artifact@v4
with:
name: static-out
path: out/
- name: Deploy via FTP
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ${{ secrets.FTP_SERVER }}
username: ${{ secrets.FTP_USERNAME }}
password: ${{ secrets.FTP_PASSWORD }}
local-dir: ./out/
server-dir: ${{ secrets.FTP_SERVER_DIR }}
dangerous-clean-slate: false
exclude: |
**/.git*
**/.git*/**
**/node_modules/**
**/.DS_Store
A few things to read carefully.
The version is pinned to @v4.3.5, not @v4 or @master. Pinning to an exact tag means a new release of the action can't change your deploy behaviour under you. On something that writes to production over FTP, that determinism is worth the occasional manual bump.
local-dir: ./out/ is the export folder from Chapter 2. server-dir is where it lands on the host, held in a secret because the exact path depends on your cPanel layout.
exclude is a list of glob patterns the action will not sync. Per the action.yml definition, exclude is "an array of glob patterns, these files will not be included in the publish/delete process." Here it drops .git*, node_modules/, and macOS .DS_Store turds. Note that .htaccess is not excluded. The exclude list uses .git*, which matches .gitignore and .gitkeep but not .htaccess.
dangerous-clean-slate: false is load-bearing and gets a full chapter of its own. The short version is to leave it false for every normal deploy so incremental syncs don't wipe the host. Chapter 8 covers the one-time true flip for the first-ever deploy and why sequencing it wrong is destructive.
The four secrets
The FTP step reads four repository secrets: FTP_SERVER, FTP_USERNAME, FTP_PASSWORD, FTP_SERVER_DIR. Add them under Settings → Secrets and variables → Actions in the repo. Per the GitHub secrets guide, you reference them with the secrets context, as in ${{ secrets.FTP_PASSWORD }}, and GitHub redacts their values from the workflow log automatically.
Two properties of that guide shape how you test.
The first is that secrets don't reach forked-PR runs. With the exception of GITHUB_TOKEN, secrets are not passed to a workflow triggered from a fork, so a fork's PR can't exfiltrate your FTP password. It's also why the deploy job guards on github.ref == 'refs/heads/main'. A fork PR couldn't deploy even if the guard were absent, but the guard makes the intent explicit and covers same-repo PRs too.
The second is that redaction isn't a licence to be careless. GitHub redacts exact secret matches, but the docs warn redaction "is not guaranteed" for transformed values (Base64, substrings). Never echo a secret, and never build a URL that splices one into a logged string.
Why FTP, not SSH?
The call
SamKirkland/FTP-Deploy-Action with the four secrets above. FTP is what every cPanel account ships with, and the action keeps a server-side state file so routine deploys sync only what changed.
Rejected
What it costs
Transient FTP timeouts happen. The recovery is a re-run of the failed job, and the state-file model means a half-finished sync heals on the next pass.
Revisit when
If the host access model changes, or deploy time grows past what an incremental FTP sync can carry.
A failure notifier that fails open
The deploy job passing is not the same as the site updating. There is a whole class of failure where the build goes green, the PR merges, and then the FTP push never lands (a port 21 timeout, a changed password) while nothing anywhere says so. The article 404s and you find out from a reader. So the workflow ends with a notify step gated on if: failure():
- name: Notify Telegram on deploy failure
if: failure()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ] || [ -z "${TELEGRAM_CHAT_ID:-}" ]; then
echo "Telegram secrets not configured — skipping notify."
exit 0
fi
# …post to the Telegram sendMessage API…
Two design choices carry the weight. The step fails open. If TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID is unset, it exits 0 rather than turning a deploy failure into a second, confusing workflow failure — the notifier is optional, so a missing secret must never break the pipeline. The step also keeps user-controlled input out of the shell. Only secrets plus a numeric run_id and a hex sha reach the script, all passed through env. That's the GitHub Actions injection-hardening rule: never interpolate arbitrary ${{ github.* }} values (branch names, PR titles) directly into a run: block, because a crafted branch name can inject shell.
Editing the workflow needs the workflow scope
One last operational gotcha, because it's cost me a failed push. GitHub protects .github/workflows/*, so editing a workflow file requires your gh token to carry the workflow OAuth scope. If it doesn't, the push is rejected specifically for the workflow change, which is baffling until you know the cause. Check with gh auth status. If workflow isn't in the scope list, run gh auth refresh -h github.com -s workflow and complete the device flow before you start editing, not after the push bounces.
What you have now
A two-job pipeline: PRs build and gate, main builds then deploys, and the deploy carries your .htaccess because include-hidden-files: true sits on the artefact upload where the stripping would otherwise happen. The one deliberately dangerous knob left is dangerous-clean-slate, still false. Chapter 8 is about the single time you flip it to true, the exact order of operations, and how to put it back before it can wipe production.
Build the artefact handoff and prove the dotfile survives
Split your own project's deploy into a build job and a deploy job that hand off through an artefact, then prove end to end that a dotfile survives the trip before anything depends on one. The proof matters more than the YAML: you are demonstrating the artefact boundary, the one place the chapter says files get silently stripped.
Expected behaviour
- The build job runs on pushes and pull requests, while the deploy job declares needs: build plus a guard limiting it to a main push or workflow_dispatch
- include-hidden-files: true sits on the upload-artifact step, with a comment explaining why it lives there and not on the FTP step
- The FTP step pins an exact version tag, and its exclude list does not match .htaccess
- gh auth status shows the workflow scope before you push the workflow file, so the push is not rejected
- Secrets are referenced only through the secrets context and never echoed or spliced into logged strings
PROVE IT Download the artefact zip from your latest run and list its contents showing the dotfile present, then remove the include-hidden-files line on a branch and show the fresh artefact losing the file.
In a two-job pipeline your .htaccess never reaches the host, with no error anywhere. Where does it get stripped?
Which upload-artifact input must be set to true so dotfiles reach the deploy job?
Why does the Telegram notify step exit 0 when its secrets are missing, and what does it deliberately keep out of the shell?
Show answer
The notifier is optional, so a missing secret must never convert a deploy failure into a second, confusing workflow failure; the step checks both variables and exits 0 if either is unset. It also keeps user-controlled input out of the shell: only secrets plus a numeric run id and a hex sha reach the script, all passed through env. Interpolating arbitrary github context values such as branch names directly into a run block is the injection risk the design avoids.
↺ re-read: “A failure notifier that fails open”