Deploying a Hugo site from GitHub Actions
A complete workflow that builds your site on every push and publishes the output, with the two settings that trip people up.

The nice thing about a static site is that deployment is just copying files. The nicer thing is that you can stop doing the copying yourself.
This is the workflow we recommend for Hugo. The shape is identical for Astro, Eleventy, Jekyll or anything else that produces a folder.
The workflow
name: Build and deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive # themes are often submodules
fetch-depth: 0 # Hugo needs history for .Lastmod
- uses: peaceiris/actions-hugo@v3
with:
hugo-version: latest
extended: true # required if your theme uses Hugo Pipes
- run: hugo --minify
- name: Publish
uses: SamKirkland/FTP-Deploy-Action@v4.3.5
with:
server: ftp.example.com
username: ${{ secrets.RACK1_USER }}
password: ${{ secrets.RACK1_PASS }}
protocol: ftps
local-dir: ./public/
server-dir: /public_html/
The two settings people get wrong
Clone the full history. With fetch-depth: 0 unset, Actions clones a single commit. Hugo derives
.Lastmod from git history, so without the full history every page claims to
have been modified at the moment of the build. If you output modification dates
in your templates or your sitemap, they will all be wrong.
Use the extended binary. Without extended: true, the standard Hugo binary cannot compile Sass. If your theme
uses Hugo Pipes for its stylesheets, the build fails with an error about
resources.ToCSS that does not obviously point at the cause.
The trailing slash matters
local-dir: ./public/ with the trailing slash uploads the contents of
public. Without it you get a public folder inside your web root and a site
that 404s. The same applies to rsync.
Deploying over SSH instead
If you prefer rsync, the same build step applies and only the publish changes:
rsync -avz --delete ./public/ you@ssh.example.com:~/public_html/
Note the ~/. Over SSH your web root sits inside your home directory, so an
absolute /public_html points at the root of the filesystem and fails. Over FTP
the account is chrooted and /public_html is correct. It is a small difference
that costs people an afternoon.
Keep the secrets in secrets
Never commit credentials. Both examples read from the encrypted secret store
(${{ secrets.NAME }} on GitHub, masked variables on GitLab). If a credential
has ever been committed, rotate it rather than deleting the commit.

