How to deploy automatically a Hugo website

| hugo git script

I while ago I asked myself if there is a way to deploy automatically all the updates of my website, which is written with Hugo framework.

Before describing the automated procedure that I ended up using, I will describe my workflow in order to make more clear my decisions.


The only thing we need to know, about how Hugo works, is that the static files that will be used by Hugo to display the website, are located in the public directory. To generate these files we execute the hugo command, which will check for changes in the articles or the HTML templates, and if there are changes it will create/edit the files in the public directory.

This is my workflow with git: I do all my changes as many times as I wish and commit as many times as I want. Best if each commit as a single scope.
When I feel that my work is ready to be released, I made sure that there are no pending changes and execute the hugo command. With the static files generated, I commit them with a message that begins always with release:, for example:

$ git commit -m "release: added 'How to deploy automatically a Hugo website'"

Manual procedure

To actually release the changes I have to SSH into the website server, go to the project directory and git pull all the changes, and finally restart the docker container: which will apply all the new changes.

Automatic procedure

After some time that I have done this procedure, I started thinking if I can execute all these steps automatically.
The first part was easy, create a cron job that will execute a bash script every morning (which is shown at the end of this article).

The second part was to create the script that will check if it is time to do a new deploy and restart the container.

Obviously, the script has to pull the latest changes, so I have to add a flag to specify the project directory.
But how to check if it is time to do a new deploy? There were two solutions that came to my mind:

The first solution didn’t convince me, even if I have this convention at the moment it could change at any time, and what if I do a typo while writing the commit message? It was not reliable.

Only one thing will remain the same over time until Hugo changes it: when I’m ready to do a release I will update the public directory. So it is clear that a good solution is to check if this directory has changed since the last time.

To check if the directory has changed I used the following git command

git diff --quiet HEAD <commit_to_compare> -- <dir_to_check>

<commit_to_compare> is the hash of the HEAD before doing git pull and <dir_to_check> is the “public” directory. This command returns 0 if there are no changes, and 1 otherwise.

This is the bash script:

function exit_w_error {
    echo "$1"
    exit 1
} >&2 # redirect to STDERR
function is_installed {
    [ -z "$1" ] && exit_w_error "Command name missing"
    PATH_SPACED=${PATH//:/ } # separate each path by space instead of :
    for individual_path in ${PATH_SPACED}; do
        # true if file exist and is executable
        [ -x "$individual_path/$COMMAND" ] && FOUND=true && break

SCRIPT_NAME=${0##*/}  # performed a string manipulation operation to get the script name
function help {
    # here-document used instead of echoing all lines
   cat <<-HMESSAGE
Perform a git pull and check if the '$DIR_TO_MONITOR' directory has changed.
If this directory has changed, restart the production container to
release the new updates.
This is possible because the Hugo framework uses the 'public' directory to
store the files that have to be published, and we update it only when
we are ready to publish.
So, if the directory is updated this means that we can publish the changes.

Syntax: $SCRIPT_NAME [-hvd]
    -h     Print this help and exit.
    -v     Make messages more verbose
    -d     Directory of the git project to check

DEBUG=':' # no-op, do nothing command
# Get the options
while getopts "hvd:" option; do
    case $option in
        h )
            exit 0;;
        v )
        d )
            echo "Error: Invalid option"
            exit 1;;
shift $((OPTIND -1)) # remove all options from $#

echo "$(date) - executing '$SCRIPT_NAME' script"

is_installed git
is_installed docker

[ -d "$DIR" ] && cd "$DIR" && $DEBUG "Moved to $DIR"

[ -d "$DIR_TO_MONITOR" ] || exit_w_error "'$DIR_TO_MONITOR' does not exist in the current directory"

PRE_PULL_HASH=$(git rev-parse HEAD)

$DEBUG 'Pulling from remote'
if [ $DEBUG = ':' ];then
    git pull --quiet
    git pull

# --quiet makes the command return 0 if there are no changes, 1 otherwise
$(git diff --quiet HEAD $PRE_PULL_HASH -- "$DIR_TO_MONITOR") && { $DEBUG "The '$DIR_TO_MONITOR' directory was not updated"; echo "NOT UPDATING the website"; exit 0; }

$DEBUG "The '$DIR_TO_MONITOR' directory was updated, restarting the production container"
echo "UPDATING the website"
docker compose restart
exit 0

This is how I set the cron job

# every day at 6:00 AM, in the Rome timezone
0 3 * * * <path to script> -d <path to website> >> <path to log file>