Deploying a website with zero downtime

How to deploy a website over SSH without any downtime. My method does not require any dependencies, and can be used with any web server such as Apache or Nginx.

Posted 2021-01-02.

Over the last few years, I have built many websites and web-based applications that I have had to deploy on various Linux servers. This post presents an easy deployment method that I have found to work in most situations.

The method works with both Apache and Nginx. It can be used to deploy content to any Linux distribution.

Prerequisites

This guide describes a setup with Nginx running on Ubuntu 20.04.

Prepare the target server

I like to keep a separate directory, under /var/www, for each domain or service that is served from every single server. This is flexible yet simple to maintain.

For the domain example.com, I would use the following directory structure:

/var/www/example.com
├── current -> /var/www/example.com/releases/1609604708
└── releases
    ├── 1609603721
    └── 1609604708

Each release is stored separately under /var/www/example.com/releases, and the current release is linked to from /var/www/example.com/current.

Prepare the target server by creating the required directory structure under /var/www/example.com:

mkdir -p '/var/www/example.com/releases'

The directory /var/www/example.com will henceforth be denoted as $ROOT in commands.

Upload a release

All releases must be given a unique name. A simple method is to use Unix time of the release. This can be done through the command date +%s (denoted as $RELEASE in future commands).

Upload the source directory site to the target server under a new directory /var/www/example.com/releases/RELEASE:

scp -qr "site" "root@example.com:$ROOT/releases/$RELEASE"

Serve the new release

Now comes the tricky part: replacing the existing site content without downtime. In order to do this, the link /var/www/example.com/current must be changed to point to the newly uploaded release.

The symbolic link can be atomically changed by first creating the link and then moving it so that it overwrites /var/www/example.com/current:

ln -s "$ROOT/releases/RELEASE" "$ROOT/releases/current"
mv -Tf "$ROOT/releases/current" "$ROOT/current"

See Artem Chistyakov's post "Atomic symlinks" for a comprehensive guide on how a symbolic link can be moved in a single instruction.

Clean up old releases

The release directory on the target server will fill up over time. It is therefore useful to delete old releases.

I often keep the five latest releases in case a rollback is required. Realistically, though, I have never had to rollback. Instead, I prefer to roll-forward at all times, making a new release with old content if necessary.

The following command can be executed on the target server in order to delete all but the five latest releases:

cd "$ROOT/releases" && \
  ls | sort -r | tail -n +6 | xargs -d '\n' -r rm -rf --

Summary

Set up a web server to serve content from /var/www/DOMAIN/releases/current (or a link pointing to it).

Deploy content to ut using the following method:

  1. Upload a new release as /var/www/DOMAIN/releases/UNIX_TIME.
  2. Create a symbolic link /var/www/DOMAIN/releases/current/var/www/DOMAIN/releases/UNIX_TIME.
  3. Rename the symbolic link /var/www/DOMAIN/releases/current to /var/www/DOMAIN/current.
  4. Delete old releases.

I have created a small script that can be used from a remote machine:

#!/usr/bin/bash
# deploy.sh

SOURCE_DIR="${1:?Missing source directory}"
DOMAIN="${2:?Missing domain}"
REMOTE="${3:?Missing target server and user}"
ROOT="/var/www/$DOMAIN"
RELEASE=$(date +%s)

# Ensure that the release directory exists.
ssh "$REMOTE" "mkdir -p '$ROOT/releases'"

# Upload the source directory to the target server.
scp -qr "$SOURCE_DIR" "$REMOTE:$ROOT/releases/$RELEASE"

# Atomically mark the release as current.
ssh "$REMOTE" "
  ln -s '$ROOT/releases/$RELEASE' '$ROOT/releases/current'
  mv -Tf '$ROOT/releases/current' '$ROOT/current'
"

# Remove old releases.
ssh "$REMOTE" "
  cd '$ROOT/releases' && \
  ls | sort -r | tail -n +6 | xargs -d '\n' -r rm -rf --
"

The script can be executed like this:

./deploy.sh 'site' 'example.com' 'root@example.com'

No external dependencies are required. Deploying content on a web server has never been easier.