<!--
  canonical-url: https://www.avonture.be/blog/github-action
  published:     2024-01-14
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# GitHub - Use Actions to deploy this blog

> Stop manual FTP deployments! Learn how to automate your blog's deployment with GitHub Actions. This step-by-step guide shows you how to set up the workflow and use repository secrets for automatic updates on every push.

---
![GitHub - Use Actions to deploy this blog](/img/v2/github_tips.webp)

**TL;DR**

This article explains how to replace manual FTP deployment scripts with a GitHub Actions workflow that automatically deploys the blog on every push. It covers creating the `.github/workflows/deploy.yml` file, storing FTP credentials as repository secrets, and using a restricted FTP user scoped only to the deployment output folder.

For the last two months, I was using an FTP automation script ([WinSCP in synchronize mode](/blog/winscp-synchronize-both)) to deploy the blog on my FTP server, as described in [Site creation](/blog/site-creation).

This way of doing things worked fine but had several inherent problems, the most important of which was that I had to run the script manually (from my computer).

If I modified an article directly from the GitHub interface or from another computer (where WinSCP was not installed, for example), there was no deployment.

By using GitHub Actions, this problem no longer exists. With each push, the blog will be updated.

> **FTP works, but it is no longer what I use:**
>
> Everything below still runs, and it is a perfectly reasonable starting point if FTP is all your
> hosting offers. It is not, however, what deploys this blog today: FTP sends your password in clear
> text and moves the site one file at a time. If your host gives you SSH access, jump to
> [the SSH/rsync version](#why-i-moved-to-ssh) at the end of this article.

## What a deployment looks like now

I push a commit — from my laptop, from another machine, or straight from the GitHub web editor — and the page `https://github.com/cavo789/blog/actions` shows this on its own:

![My action is running](./images/action_is_running.webp)

By clicking on the running action, the details of each step are displayed and I can easily follow along:

![Pushing files](./images/pushing.webp)

Four minutes later, the action is green and the blog is up to date online. I haven't opened an FTP client, and I haven't been anywhere near the machine where WinSCP is installed.

## Three ingredients

A workflow file committed in the repository, three repository secrets holding the FTP credentials, and an FTP user restricted to the deployment folder. That's the entire setup — no runner to host, no service to subscribe to.

## Setting it up

To enable `GitHub actions`, we first need to create a file in the folder `.github/workflows`. Mine will be named `deploy.yml` with this content:

**.github/workflows/deploy.yml**

```yaml
name: Deploy blog on avonture.be

on:
  push:
    branches:
      - main
  # Manual trigger: use clean-slate to recover from a corrupted FTP sync state.
  # A corrupted state causes error 553 ("replacing … No such file or directory")
  # because the action skips directory creation for files it thinks already exist.
  # Fix: run this workflow manually with clean_slate=true once, then go back to normal.
  workflow_dispatch:
    inputs:
      clean_slate:
        description: "Delete all remote files and re-upload everything (recovers from FTP sync errors)"
        required: false
        default: "false"
        type: choice
        options:
          - "false"
          - "true"

permissions:
  contents: write

jobs:
  deploy:
    name: Deploy blog on avonture.be
    runs-on: ubuntu-latest
    if: github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'wip') == false
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          # Full git history is required so the blog plugin's showLastUpdateTime /
          # showLastUpdateAuthor can read each post's real last-commit date/author
          # instead of the single shallow-clone commit.
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "yarn"

      - name: Cache Node.js modules
        id: cache-npm
        uses: actions/cache@v4
        with:
          path: |
            node_modules
            ~/.yarn
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-

      - name: Install dependencies
        if: steps.cache-npm.outputs.cache-hit != 'true'
        run: yarn install --frozen-lockfile

      - name: Build website
        run: yarn build

      - name: Push files
        uses: SamKirkland/FTP-Deploy-Action@v4.3.5
        with:
          # See https://github.com/SamKirkland/FTP-Deploy-Action for allowed settings
          server: ${{ secrets.ftp_server }}
          username: ${{ secrets.ftp_login }}
          password: ${{ secrets.ftp_password }}
          # We need to deploy the build folder from GitHub to our FTP; not everything (f.i. don't need node_modules)
          local-dir: ./build/
          # Deletes all remote files before uploading — use only via manual trigger
          # to recover from a corrupted .ftp-deploy-sync-state.json (error 553).
          dangerous-clean-slate: ${{ github.event.inputs.clean_slate == 'true' }}
```

As you can see, I need three secrets, `${{ secrets.ftp_server }}`, `${{ secrets.ftp_login }}` and `${{ secrets.ftp_password }}`.

> **Make sure to use a restricted FTP user:**
>
> Don't use an overly privileged user. Create a new one, just for your blog, with access to only the output folder (like `/var/www/html/public`) where your blog should be deployed.

I need to create them in my Settings page for my repository: `https://github.com/cavo789/blog/settings/secrets/actions` i.e. `Settings` -> `Secrets and variables` -> `Actions`.

In the `Repository secrets` area, I have clicked on the `New repository secret` button and create the first one: `FTP_LOGIN` and provide the login. Same thing with the two other secrets.

This done, I can push my local changes (the `.github/workflows/deploy.yml`) to GitHub using `git add .github/workflows/deploy.yml && git commit -m "chore: add deploy github action" && git push`.

## Why I Moved to SSH

Three things about this setup bothered me enough to eventually replace it, and none of them are
about GitHub Actions — they are all about FTP as a transport.

**The credentials travel in clear text.** Plain FTP has no encryption. The password, and every byte
of the site, cross the network readable by anything sitting in between. FTPS (explicit TLS on the
same port) fixes that and costs one line of configuration, so if you stay on FTP, at least use it.

**One file at a time.** FTP opens a separate data connection per file. On a site of a few thousand
files, the overhead dominates: the transfer spends its time negotiating rather than sending. That is
the four minutes you saw earlier — almost none of it is actual data.

**A third party sits between the key and the server.** The action doing the transfer is code I do
not control, and I hand it my credentials on every run. That is a reasonable trade for the
convenience, but it is a trade, and it is worth making consciously.

The replacement uses `rsync` over SSH: encrypted by default, one connection for the whole site, and
only the files whose content actually changed cross the wire.

## Conclusion

What disappeared here isn't a technology, it's a habit: the little WinSCP script I had to remember to run, from the one computer where it was installed. Publishing is now a side effect of pushing, which is something I do anyway.

Deployment is not the only thing worth automating on GitHub; [Automate your GitHub README with your latest blog posts](/blog/github-profile-last-blogposts) uses the same mechanism to keep my profile page up to date.
