clubmate.fi

A good[ish] website

Web development blog, loads of UI and JavaScript topics

Super simple static site deployment bash script, that keeps Git in sync

Filed under: System— Tagged with: bash

An uncomplicated deploy script that doesn’t use Git, but still keeps your upstream Git repo up-to-date with the live server.

I’ve been using this simple script to deploy few side project for couple of years now. There are brilliant services like Vercel or Netlify that let you deploy static sites super easily. But this one is a 100% DIY solution, which assumes you have a VPS (like DigitalOcean etc.) with an ssh access.

Requirements for the "simple" deploy script

Basically a dumb script that deploys to a simple server, in my case it only assumes that rsync works to the target machine. If you deploy to an AWS bucket, you can change the upload step on the script.

No Git: the actual transporting of the files doesn’t happen via Git. This way Git is not needed to be installed onto the server, nor have any hooks listening. Which enables to roll with a really simple, perhaps rootless server setup, etc.

Build locally: if the build step happens locally, it also doesn’t expect anything from the server.

Keep Git and the server in sync: the problem with having the build step run locally, is that the deploy script can be run before the new code has been pushed upstream (Github, GitLab...), and then your server isn’t up-to-date with the the remote repo. But this can be solved with a bit of locally run Git smartness.

The script

I’ll divide the explanation into 5 parts (or jump to the full script):

  • Setup
  • Tools
  • Handle Git
  • Transport the files
  • Run the functions

Setting things up

#!/usr/bin/env bash

set -o errexit
set -o pipefail
set -o nounset
# set -o xtrace

readonly PROJECT_NAME='example'
readonly TARGET_PATH='/var/www/example.com/public_html'
readonly SOURCE_PATH='public/'
readonly SITE_URL='https://example.com'
readonly SERVER="${1:-}"

First, it sets on the the unofficial Bash strict mode, in this case written in longhand syntax. It could also be written as set -euo pipefail. It tells Bash to: 1) stop executing the script if an error happens, 2) Error and stop the script if an undefined variable is being called, 3) prevent errors in a pipeline from being masked.

Second: configure the deployment script, paths and names.

Tools

These totally optional helper functions are only for printing different colored messages to the terminal as you deploy. More on the colors in Bash.

green() {
  echo "\\033[32m${1}\\033[m"
}

red() {
  echo "\\033[31m${1}\\033[m"
}

yellow() {
  echo "\\033[33m${1}\\033[m"
}

Handle Git

This is the most interesting bit of the code I think. It uses diffing and other Git wizardry to figure out if the local Git tree is clean and up-to-date with the upstream one.

die_if_wrong_project() {
  local CURRENT_PROJECT=$(basename "$(git rev-parse --show-toplevel)")

  if [ "$CURRENT_PROJECT" != "$PROJECT_NAME" ]; then
    red "\\n😱  wrong project, this script is configured to be used in a project named ${PROJECT_NAME}\\n"
    exit 1
  fi
}

die_if_dirty() {
  local CHANGED_FILES=$(git diff-index --name-only HEAD --)

  if [ "$CHANGED_FILES" != "" ]; then
    red "\\n😱  the repository is not clean:\\n ${CHANGED_FILES}\\n"
    exit 1
  fi
}

die_if_untracked_files() {
  local UNTRACKED=$(git ls-files --exclude-standard --others)

  if [ "$UNTRACKED" != "" ]; then
    red "\\n😱  there are untracked files:\\n ${UNTRACKED}\\n"
    exit 1
  fi
}

die_if_ahead_of_remote() {
  local UPSTREAM="@{u}"
  local LOCAL=$(git rev-parse @)
  local REMOTE=$(git rev-parse "$UPSTREAM")

  if [ "$LOCAL" != "$REMOTE" ]; then
    red "\\n😱  the local branch is not up to date with the remote, please do a git push\\n"
    exit 1
  fi
}

Explanations:

die_if_wrong_project()
This is just a precaution, since we’re doing some rm stuff, better check if this is the right Git project so it doesn’t reek havoc if run in a wrong directory.
die_if_dirty()
This function will exit with the script if the Git tree is dirty, i.e.: there are uncommitted changes.
die_if_untracked_files()
This function will exit the script if there are untracked files loitering around.
die_if_ahead_of_remote()
Exits the script if you forgot to push changes to the upstream branch.

Move the files

The function that physically moves the files over to the server using rsync:

move_files_to_server() {
  yellow "File transfer started, might take a moment."
  rsync -az --info=progress2 --exclude=".*/" --chmod=+rwx --delete "${SOURCE_PATH}" "${SERVER}":"${TARGET_PATH}"
  green "Files moved to ${SERVER}:${TARGET_PATH}."
}

Here’s what the rsync does:

-a, --archive
This is equivalent to -rlptgoD. It is a quick way of saying you want recursion and want to preserve almost everything (with -H being a notable omission).
-z, --compress
With this option, rsync compresses the file data as it is sent to the destination machine, which reduces the amount of data being transmitted.
--info=progress2
This option outputs statistics based on the whole transfer, rather than individual files. Note that this only applies to rsync versions 3.0 or newer. See here how to update rsync if you’re running an old version.
--exclude=".*/"
This excludes dotfiles, for example .env, which is super important!
--chmod=+rwx
Make the transferred files readable.
--delete
This tells rsync to delete extraneous files from the receiving side (ones that aren’t on the sending side), but only for the directories that are being synchronized. Meaning: it will keep the directories synchronized.

Run the functions and build

Then lastly, run all the functions and the build step. In this case I have the build script as an npm script. First run the sanity check, then build, then move the files:

yellow "Started the deploy process, may take a moment..."
die_if_wrong_project
die_if_dirty
die_if_untracked_files
die_if_ahead_of_remote
npm run build
move_files_to_server
green "Deployd to: ${SITE_URL}. Have a nice day!"

The whole deploy script

Below is the wle script. You need to of course configure it to fit your project.

Run it like so:

$ sh deploy.sh bob@192.168.0.0

Or put it in your projects package.json along with the other scripts your project needs:

{
  "scripts": {
    "deploy": "sh bin/deploy.sh bob@192.168.0.0",
    "develop": "gatsby develop",
    "clean": "gatsby clean",
    "build": "gatsby build",
    "serve": "gatsby serve"
  }
}

The whole deploy.js script:

#!/usr/bin/env bash

set -o errexit
set -o pipefail
set -o nounset
# set -o xtrace

readonly PROJECT_NAME='example'
readonly TARGET_PATH='/var/www/example.com/public_html'
readonly SOURCE_PATH='public/'
readonly SITE_URL='https://example.com'
readonly SERVER="${1:-}"

green() {
  echo "\\033[32m${1}\\033[m"
}

red() {
  echo "\\033[31m${1}\\033[m"
}

yellow() {
  echo "\\033[33m${1}\\033[m"
}

# Since we're doing some rm stuff, better check if this is the right Git project
# so it don't reek havoc if run in a wrong directory
die_if_wrong_project() {
  local CURRENT_PROJECT=$(basename "$(git rev-parse --show-toplevel)")

  if [ "$CURRENT_PROJECT" != "$PROJECT_NAME" ]; then
    red "\\n😱  wrong project, this script is configured to be used in a project named ${PROJECT_NAME}\\n"
    exit 1
  fi
}

die_if_dirty() {
  local CHANGED_FILES=$(git diff-index --name-only HEAD --)

  if [ "$CHANGED_FILES" != "" ]; then
    red "\\n😱  the repository is not clean:\\n ${CHANGED_FILES}\\n"
    exit 1
  fi
}

die_if_untracked_files() {
  local UNTRACKED=$(git ls-files --exclude-standard --others)

  if [ "$UNTRACKED" != "" ]; then
    red "\\n😱  there are untracked files:\\n ${UNTRACKED}\\n"
    exit 1
  fi
}

die_if_ahead_of_remote() {
  local UPSTREAM="@{u}"
  local LOCAL=$(git rev-parse @)
  local REMOTE=$(git rev-parse "$UPSTREAM")

  if [ "$LOCAL" != "$REMOTE" ]; then
    red "\\n😱  the local branch is not up to date with the remote, please do a git push\\n"
    exit 1
  fi
}

move_files_to_server() {
  yellow "File transfer started, might take a moment."
  rsync -az --info=progress2 --exclude=".*/" --chmod=+rwx --delete "${SOURCE_PATH}" "${SERVER}":"${TARGET_PATH}"
  green "Files moved to ${SERVER}:${TARGET_PATH}."
}

yellow "Started the deploy process, may take a moment..."
die_if_wrong_project
die_if_dirty
die_if_untracked_files
die_if_ahead_of_remote
npm run build
move_files_to_server
green "Deployd to: ${SITE_URL}. Have a nice day!"

Possible improvements

The script could take a version number as the second arg, and then create a tag based on that, so rollbacks would be possible by checking out the previous tag.

It could have a --force option that ignores the Git stuff.

The script could also be configured with args or a config file, it could read in package.json etc.

Comments would go here, but the commenting system isn’t ready yet, sorry. Tweet me @hiljaa if you want to make a correction etc.

  • © 2021 Antti Hiljá
  • About
  • Follow me in Twatter → @hiljaa
  • All rights reserved yadda yadda.
  • I can put just about anything here, no one reads the footer anyways.
  • console.log('Smash the patriarchy!')
  • I love u!