Hugo + GitHub Actions: a simple deployment pipeline

Table of Contents
Introduction#
The simplest way to automatically deploy a Hugo site is this: keep the whole project in a GitHub repository, and let GitHub Actions build the site on every push and upload the generated public/ directory to your server over SSH using rsync.
Assumptions#
Here I assume two things.
First, you already have a working Hugo project locally. For example, you created it with hugo new site your_site and you checked that running the hugo command locally correctly generates the public/ directory.
Second, your hosting provider gives you SSH access. This means you have things like: a login, a host name, and a target directory where the site files should go. Usually this is something like public_html or www.
Preparing the repository#
At this stage, I usually do three simple steps.
First, I push the whole Hugo project to a new GitHub repository. I exclude the public/ directory from the repo by adding it to .gitignore, because this directory will be generated in the pipeline and should not live in the Git history.
Second, in the config file hugo.toml or hugo.yaml (in older versions config.yaml or config.toml) I set the correct baseURL value. This value should point to the final domain where your site will be available. Thanks to this, all generated links will be correct right after deployment.
SSH key and GitHub secrets#
At this point, you need a separate SSH key used only for deployment, so it does not mix with keys you use every day. On your machine, you generate a new key pair, for example with: ssh-keygen -t ed25519 -f ~/.ssh/hugo_deploy.
The next step is to connect this key to the server where you will upload the files. You append the contents of the public key file hugo_deploy.pub to the ~/.ssh/authorized_keys file on your hosting, for the specific user account you will use for deployment. This way GitHub Actions will be recognized on the server as a normal SSH user, but limited to whatever this hosting account can do.
Then you configure secrets in the GitHub repository. In the repo settings, go to Settings → Secrets and variables → Actions → New repository secret and define the variables that your workflow will use. This area is made for storing things like keys and passwords, so they do not end up in code or Git history.

In practice, you add a few important secrets that the deploy step will use. A typical set is:
DEPLOY_KEY– the content of the private SSH key (the file without the .pub extension),DEPLOY_HOST– the server address, for example ssh.example.com,DEPLOY_USER– the login on the server,DEPLOY_DIRECTORY– the target directory for your files, for example/home/user/public_html.
This setup lets you keep all deploy logic inside the workflow file, and all sensitive data inside GitHub secrets, which is the standard practice for automated deployments with GitHub Actions.
Repository structure and basic build#
In this approach, the repository is the single source of truth for the whole Hugo project, not just a storage place for already generated HTML. This means the repo should contain directories like content/, layouts/, themes/, static/, and config files config.* or hugo.* – in short, the full source code from which hugo can build the site from scratch. Thanks to that, GitHub Actions can reproduce the whole build process on a clean runner, without any artifacts from your local machine.
The public/ directory itself is a build artifact, so I do not keep it in the repo. I add it to .gitignore so that the history is not polluted by files that are generated automatically every time hugo runs. For the same reason, I also ignore the .hugo_build.lock file – this is an empty file created automatically by Hugo as a lock when building or running commands like hugo new, so it is safe to treat it as a temporary local file and not track it in Git. This pattern also works well with other static site generators: keep the code in the repo, and produce the final output only in the pipeline and on the server or CDN.
In the GitHub Actions workflow, the basic build step is very simple. Usually the hugo command is enough, or hugo –minify if you want minified CSS and JS out of the box. After this step, the runner has a complete set of static files in the public/ directory, ready to be sent to the server.
To avoid surprises when versions change, it is good to install a specific Hugo version in every workflow run. For this you can use ready-made actions from GitHub Marketplace, such as peaceiris/actions-hugo or hugo-setup. They let you pin an exact Hugo version (for example 0.152.2 or latest) and prepare the environment on the runner in a consistent way before you call hugo.
Installing the Hugo theme (submodule)#
The GitHub Actions build will behave exactly like your local build only if the theme is also available in the repository – in the themes/<theme_name> directory or as a Hugo module, because the generator looks for themes there during the build. If the theme is not on GitHub, the runner has nothing to build the layouts from and the build will fail.
In this article, I assume one specific approach: the theme as a Git submodule. This gives you two benefits at once: you keep the theme as a separate repository (easy updates from upstream), and at the same time you lock your project to a specific commit, so builds are repeatable.
Git stores a submodule as a pointer to a specific commit in the theme repository, not as “always the latest version” from a branch. Thanks to this, the theme will not update itself automatically on the next build – the GitHub Actions runner will always pull exactly the version that you have recorded in your main repository. If you want to move to a newer theme version, you do it explicitly: update the submodule locally, test if everything works, and only then commit the new SHA to your repo.
If earlier you cloned the theme “directly” into themes/theme-name (for example with git clone … themes/theme-name), first remove or move that directory:
rm -rf themes/theme-name
Next, add the theme as a submodule, pointing to its official repository and the target directory:
git submodule add https://github.com/{theme-name}.git themes/theme-name
git status
As a result, the main repo will now contain a .gitmodules file and an entry for themes/theme-name visible as a submodule in the project tree. You need to commit these changes like any other code, so that GitHub actually sees the .gitmodules file and the reference to themes/theme-name. Without that, the Actions runner will not know it should fetch an extra repository for the theme.
GitHub Actions workflow (deploy via rsync)#
In the next step, we add a workflow that runs the whole build + deploy process after every push. In the repository, create the file .github/workflows/deploy.yml and fill it with a job definition that: checks out the code, installs the selected Hugo version, builds the site into the public/ directory, and finally calls the rsync-deployments action (or a similar one) to upload the files to the server. These actions are available on GitHub Marketplace and are built around rsync over SSH, so they work nicely with the SSH key and secrets prepared before.
name: Deploy Hugo Site
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: true
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.152.2' # Pick specific version
extended: true
- name: Build Hugo
run: hugo --minify --gc
- name: Deploy via Rsync
uses: Burnett01/rsync-deployments@5.2
with:
switches: -avzr --delete
path: public/
remote_path: ${{ secrets.DEPLOY_DIRECTORY }}
remote_host: ${{ secrets.DEPLOY_HOST }}
remote_user: ${{ secrets.DEPLOY_USER }}
remote_key: ${{ secrets.DEPLOY_KEY }}
The uses key in each step points to a ready-made GitHub Action – an external reusable block that does a specific job for that step. Such an action is just a packaged piece of code (most often JavaScript or a Docker-based action) that runs a series of system commands inside the runner. For example, actions/checkout@v4 checks out your repository into the CI environment, and peaceiris/actions-hugo@v3 installs Hugo in the chosen version so you can immediately run the hugo command.
Usually, I configure this workflow to run on pushes to the main branch. This way every merge into it triggers a new build and file sync to the server. In the rsync step, I set the source directory: public/, the target: DEPLOY_USER@DEPLOY_HOST:DEPLOY_DIRECTORY, and options like --archive, --compress, and --delete to keep full synchronization.
The --delete flag is especially important. With this option, rsync removes from the server all files that are no longer present in the current public/ directory, so the hosting always reflects the exact state of the last build. Old pages or assets that used to exist in the project and were later removed will also disappear from the server.
Hugo build flags: –minify and –gc#
In the build step, I use two flags that optimize the output and ensure consistency with local builds.
The --minify flag enables automatic minification of generated HTML, CSS, JavaScript, JSON, SVG, and XML files. Hugo uses the tdewolff/minify library internally to reduce file sizes by removing unnecessary whitespace, comments, and optimizing code structure. For a typical blog, this can reduce the total size of HTML and CSS by 20-30%, which directly translates to faster page loads and better PageSpeed Insights scores. The minification is lossless – the site looks and works exactly the same, just with smaller files.
The --gc flag stands for “garbage collection” and tells Hugo to clean up unused files from the build cache. During a build, Hugo may generate temporary processed assets (minified CSS, fingerprinted files, resized images) in resources/_gen/. The --gc flag removes files that were generated but are not referenced in the final output. In GitHub Actions, where each runner starts with a clean state, this flag has less impact than in local builds where the cache accumulates over multiple runs. However, I include it in the workflow for two reasons: first, it ensures the build command is identical to what you would run locally (good for reproducibility and debugging), and second, it guarantees that even within a single build, any orphaned temporary files are cleaned up before deployment.
Using both flags – hugo --minify --gc – is a solid practice for production builds, giving you smaller files for users and a lean, predictable build output.
Running and debugging#
When the workflow file is ready, I do a normal commit and git push to GitHub. In the “Actions” tab in the repository, a new workflow run appears immediately, and I can inspect the details of each step. This is a good place to verify that installing Hugo, the build itself, and the rsync deploy step all complete without errors.
If something goes wrong, the job logs usually make the cause very clear. SSH-related problems (for example wrong key, host, user, or port) will show up in the deploy step, wrong directory paths will appear in the rsync output, and issues with Hugo configuration will show already in the hugo step. This helps you quickly see whether you need to fix secrets, server paths, or the Hugo project configuration.
After a successful run, I open the browser and check if the new version of the site is visible on the target domain. From this moment on, every new commit to the main branch automatically refreshes the content on the hosting, so deploy becomes a natural part of git push, not a separate manual task.
If your hosting does not support SSH, the overall Hugo build flow in GitHub Actions stays the same, only the final upload step changes. Instead of rsync, you can use one of the FTP or SFTP actions from the Marketplace, just updating the secret variables and the deploy configuration. The rest of the pipeline – checkout, Hugo setup, and build – works exactly the same.
Summary#
Setting up this pipeline usually takes about 15 minutes and then saves a lot of time in the future. Instead of building the site by hand and copying files over FTP, you simply run git push and everything else happens automatically. Once it is configured, publishing new posts becomes almost invisible in your daily work – you focus on writing, and the tooling quietly does its job in the background.
Hugo deploy: TL;DR checklist#
- Prepare the repo: Push your Hugo project to GitHub (without the public/ directory and without .hugo_build.lock).
- Handle the theme: If you use an external theme, add it as a submodule (git submodule add …) so GitHub Actions can clone it.
- SSH keys:
- Generate a new key pair (ssh-keygen -t ed25519).
- Add the public key (.pub) to ~/.ssh/authorized_keys on your hosting.
- Add the private key as the DEPLOY_KEY secret in your GitHub repo.
- Secrets: In the repository (Settings → Secrets), set these variables: DEPLOY_HOST, DEPLOY_USER, DEPLOY_KEY and DEPLOY_DIRECTORY.
- Workflow: Create .github/workflows/deploy.yml that:
- Checks out the code with submodules: true.
- Installs Hugo and builds the site (hugo –minify).
- Uploads the contents of public/ to the server using rsync.
- Push: Push your changes to main and check in the “Actions” tab that the build is green.