On this page

Chapter 7 of 8. Prerequisite: Chapter 5 — you should already have the public/.htaccess that ships into out/.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.

// DECISION

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
tar piped over SSHthis site ran exactly that as an emergency workaround once, while GitHub Actions billing was blocked. It works, needs shell access enabled, and re-uploads everything every time.
cPanel's Git Version Controlpull-based with no build step, so the host would need Node and the build would run on shared hosting
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.

// EXERCISE

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.

// CHECKPOINT — DEPLOY PIPELINE
multiple choice · auto-checked

In a two-job pipeline your .htaccess never reaches the host, with no error anywhere. Where does it get stripped?

exact answer · auto-checked

Which upload-artifact input must be set to true so dotfiles reach the deploy job?

open · self-checked

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

Lived experience

Sources

  • SamKirkland/FTP-Deploy-Action — Deploys a GitHub project to a FTP server using GitHub actions
    GitHub (SamKirkland)
    The action's README settings table — confirms its actual input set (server, username, password, local-dir, server-dir, exclude, dangerous-clean-slate) and that include-hidden-files is NOT one of them
    github.com
  • FTP-Deploy-Action/action.yml at master
    GitHub (SamKirkland)
    The source-of-truth input definition — enumerates every input the action accepts and the exact wording of dangerous-clean-slate and exclude
    github.com
  • Using secrets in GitHub Actions
    GitHub Docs
    How the four FTP secrets are consumed via the secrets context, log redaction, and why they don't reach forked-PR runs
    docs.github.com
  • Workflow syntax for GitHub Actions
    GitHub Docs
    The on triggers (push / pull_request / workflow_dispatch) and the two-job build/deploy layout this workflow is built from
    docs.github.com
Back to guide overview