A beginner’s guide to deploying static sites with versioning and rollbacks using Flightplan

With the rise of cheap VPS (Virtual Private Server) services and the increase of complexity in the architecture of new web applications, deployment processes are becoming a very important topic and a skill to master to some extent.

Long gone the days when we just needed a cheap hosting service and an FTP access to be able to setup and update our static websites.

Furthermore, it is worth considering that software development has become a lot more collaborative thanks to tools like Git and services like GitHub and therefore people are getting used to the benefits of versioning. This brought in the idea of being able to keep our deploys versioned as well and to be able to roll back to a previous version easily in case a new deploy ends up to break something.

In this article we will learn how to setup a VPS (or a test virtual machine) to serve a static website with Nginx and how to create a simple yet effective deployment process to keep our website updated. Of course we will take care of integrating versioning and rollbacks in the process.

I am assuming you already have a basic knowledge of Bash, Git, SSH and Ubuntu but I will try to make things as clear as possible so that, even if you are a newbie, you should be able to understand and follow the tutorial.

Also, you will need to have Git and NodeJs installed on your local machine.

If you like this article, give it some sharing ❤️

Setup of the machine

The first thing to do is to have a server machine that we will need to be configured to serve our website. My advice is to get a basic VPS on DigitalOcean or Linode. It will cost you very little money ($5-10/month) and you can shut down the machine once you are done with the tutorial and save the money for when you need to serve your next real website.

As an alternative, you can also use a local virtual machine to simulate a remote server. In this case my advice is to go with Vagrant.

In case you chose the first option, here are some tutorials that will explain you how to run a new machine and how to establish a connection through SSH:

In case you want to go with the local virtual machine option here’s another guide for you to get started with Vagrant.

Whatever path you choose I expect you have the following requirements before moving on with the rest of the tutorial:

  1. You have a remote machine up and running with Ubuntu installed
  2. You know the IP address (or the domain name) of the machine
  3. You have a SSH key pair for your local machine
  4. You have authorized your key pair to connect to your remote machine with SSH

So, just to make things clear, we will work on two different machines. The local machine (our computer) for development and the remote machine (our server) as production environment to serve our website to the World. All the next paragraphs will be labeled with “(local)” or “(remote)” to make clear where we will perform our actions.

Install Nginx (remote)

I am assuming your server is running Ubuntu (at the time of writing the last LTS version is 14.04). The first thing we need to do is to login with SSH to our server and install Nginx. In case you never heard of it, Nginx is one of the most famous web servers. It is really easy to install and configure and it also offers very good performances. You can use Apache as an alternative but we will stick with Nginx in this tutorial. To install Nginx on Ubuntu you just need to be sure you are connected to your remote server and to run the following command in your bash prompt:

sudo apt-get install -y nginx

If everything went fine we now have Nginx installed. If you open your browser and visit http://yourserver/ (where yourserver is the real IP or the domain name of your server) you should be able to see the default Nginx welcome page. We will change that later with our real website.

We will also need to have git installed on our server, so let’s add it:

sudo apt-get install -y git

For now our server is ok, we will see in a moment how to configure it for our needs, but let’s create a new website on our development machine first!

Create a sample website (local)

In this paragraph we will create a very simple website and start to version it using git.

Let’s just create a new folder in our local machine, open a command line shell in that folder and run:

git init

For the sake of simplicity, in our website we are going to have two files: a HTML home page and a CSS file.

Our HTML file is called index.html and it should look like this:

<html>
 <head>
 <meta charset="utf-8">
 <title>My wonderful website</title>
 <link rel="stylesheet" href="style.css">
 </head>
 <body>
 <h1>Welcome to my wonderful website</h1>
 <p>You will never find another place as cool as this</p>
 </body>
</html>

Pretty simple! Now let’s move to our style.css:

 

css
 html {
 font-family: sans-serif;
 -ms-text-size-adjust: 100%;
 -webkit-text-size-adjust: 100%;
 }

body {
 margin: 0;
 }
article,
 aside,
 details,
 figcaption,
 figure,
 footer,
 header,
 hgroup,
 main,
 menu,
 nav,
 section,
 summary {
 display: block;
 }
audio,
 canvas,
 progress,
 video {
 display: inline-block;
 vertical-align: baseline;
 }
audio:not([controls]) {
 display: none;
 height: 0;
 }
[hidden],
 template {
 display: none;
 }
a {
 background-color: transparent;
 }
a:active,
 a:hover {
 outline: 0;
 }
abbr[title] {
 border-bottom: 1px dotted;
 }
b,
 strong {
 font-weight: bold;
 }
dfn {
 font-style: italic;
 }
h1 {
 font-size: 2em;
 margin: 0.67em 0;
 }
mark {
 background: #ff0;
 color: #000;
 }
small {
 font-size: 80%;
 }
sub,
 sup {
 font-size: 75%;
 line-height: 0;
 position: relative;
 vertical-align: baseline;
 }
sup {
 top: -0.5em;
 }
sub {
 bottom: -0.25em;
 }
img {
 border: 0;
 }
svg:not(:root) {
 overflow: hidden;
 }
figure {
 margin: 1em 40px;
 }
hr {
 box-sizing: content-box;
 height: 0;
 }
pre {
 overflow: auto;
 }
code,
 kbd,
 pre,
 samp {
 font-family: monospace, monospace;
 font-size: 1em;
 }
button,
 input,
 optgroup,
 select,
 textarea {
 color: inherit;
 font: inherit;
 margin: 0;
 }
button {
 overflow: visible;
 }
button,
 select {
 text-transform: none;
 }
button,
 html input[type="button"],
 input[type="reset"],
 input[type="submit"] {
 -webkit-appearance: button;
 cursor: pointer;
 }
button[disabled],
 html input[disabled] {
 cursor: default;
 }
button::-moz-focus-inner,
 input::-moz-focus-inner {
 border: 0;
 padding: 0;
 }
input {
 line-height: normal;
 }
input[type="checkbox"],
 input[type="radio"] {
 box-sizing: border-box;
 padding: 0;
 }
input[type="number"]::-webkit-inner-spin-button,
 input[type="number"]::-webkit-outer-spin-button {
 height: auto;
 }
input[type="search"] {
 -webkit-appearance: textfield;
 box-sizing: content-box;
 }
input[type="search"]::-webkit-search-cancel-button,
 input[type="search"]::-webkit-search-decoration {
 -webkit-appearance: none;
 }
fieldset {
 border: 1px solid #c0c0c0;
 margin: 0 2px;
 padding: 0.35em 0.625em 0.75em;
 }
legend {
 border: 0;
 padding: 0;
 }
textarea {
 overflow: auto;
 }
optgroup {
 font-weight: bold;
 }
table {
 border-collapse: collapse;
 border-spacing: 0;
 }
data-language="css">td,
 th {
 padding: 0;
 }
/** ------ END NORMALIZE.CSS ------ **/
body h1,
 body p {
 text-align: center;
 }

As a starting point, our css file includes Normalize.css, an open source stylesheet crafted to minimize the differences in the way different browsers handles some properties. It helps web designer to obtain the same graphic layout through all the major browser.

So if we want to see how our new amazing web site looks like we just need to open our index.html.

website preview version

Isn’t a beautiful piece of art? Ok, maybe it’s not, but it’s a decent starting point for you next great thing 🙂

Now let’s add the new files to our local git repository. Go to the command line and launch the following command:

git add index.html style.css

Then we can make our first commit:

git commit -am "first version of the website"

At this stage our git repository lives only on our local machine, so nobody will be able to read it from the outside, neither our server will be able to download the files when we want to release a new version of the project.

The next thing to do is to add a remote repository. To understand what remote repositories are let’s quote the official git documentation:

To be able to collaborate on any Git project, you need to know how to manage your remote repositories. Remote repositories are versions of your project that are hosted on the Internet or network somewhere. You can have several of them, each of which generally is either read-only or read/write for you. Collaborating with others involves managing these remote repositories and pushing and pulling data to and from them when you need to share work.

So now we have one problem: where do we have to put our remote repository? The easiest solution is to get an account on GitHub and create a new public repository there. GitHub is free to use for public repositories, but you can purchase a paid account to have private repositories in case your repository contains sensible information that you don’t want to share.

Once you have your account on GitHub, you can create a new repository using their web interface:

create a new repository

Let’s add a name and a description, be sure to select public and click the “Create repository” button.

Once done you should be able to see something like this:

your public github repo for static websites

As you can tell, our project file are not there yet, let’s push them!

To push our files we need to associate our local repository to the new remote one, so copy your repository address in the right column (you can choose https or ssh URLs, I recommend to use the former) and get back to your command line console with the following command:

git remote add origin

Be sure to replace with the URL you copied from your GitHub repository page.

Now we have associated our local repository with the new GitHub remote one. GitHub creates some files when you create a new repository, so it’s a great thing to download them into your local repository:

git pull origin master

This will make you ready to push our file to the remote repository

git push --set-upstream origin master

If you refresh your GitHub project page, you will now see all our project files are there.

Prepare the deployment scripts (local)

We now have our website and our server and at this stage we can move to the core of our article: how to organize a process to simplify the deployment of our website in the new server.

There are hundreds of tools and SaaS platforms to manage complex deployment processes on one or more servers, but one of my favourite is Flightplan. Flightplan is a NodeJS library that can be easily installed and configured to create a sequence of shell commands in our local machine and in our remote server to deploy and update our website or app.

The first thing to do is to install Flightplan in your local machine:

npm install -g flightplan
npm install flightplan --save-dev

The first command installs Flightplan as a global command (the fly command) in your system, the second one allows us to use the Flightplan library inside your project.

Now we need to create a configuration file inside the main folder of our project called flightplan.js.

Before writing the code in our Flightplan configuration file let’s understand what exactly we want to achieve and how we want to structure our files and folders to do so.

As we said, our objective is to be able to deploy a new version of our website with a single command and to be able to rollback to the previous version just by launching another command. So we expect to have at least two different commands: deploy and rollback.

To achieve this goal we can structure the folders in our remote server as follows:

  • repo: a folder used to download the data (clone) from our git repository. It will allow us to download only the data that changed since the last time we deployed, so it will be really efficient.
  • versions: in this folder we will have different subfolders, one for every deploy.
  • current: a symlink to the last deployed version in the versions folder. It’s the folder that will be used to configure Nginx and the only one that will be shown online.

The flow we want to create for every deploy is the following:

  1. the repo folder is updated to the last version using git pull
  2. the repo folder is copied as subfolder inside versions with a convention name that is given by the current timestamp on the server (this allows us to keep versions easily ordered)
  3. the newly copied folder is “symlinked” to the current folder

Furthermore we want to add a nice feature to save disk space: we want to have a fixed numbers of deployed versions on the server (e.g. no more than 10), so we might need to delete the older ones at the end of every deploy.

// flightplan.js
var plan = require('flightplan');

/**
 * Remote configuration for "production"
 */
plan.target('production', {
  host: 'example.com',
  username: 'someuser',
  password: 'somepassword',
  agent: process.env.SSH_AUTH_SOCK,

  webRoot: '/var/www/mywebsite',
  ownerUser: 'www-data',
  repository: 'https://github.com/someuser/example-com.git',
  branchName: 'master',
  maxDeploys: 10
});

/**
 * Creates all the necessary folders in the remote and clones the source git repository
 * 
 * Usage:
 * > fly setup[:remote]
 */
plan.remote('setup', function(remote) {
	remote.hostname();

	remote.sudo('mkdir -p ' + remote.runtime.webRoot);
	remote.with('cd ' + remote.runtime.webRoot, function() {
		remote.sudo('mkdir versions');
		remote.sudo('git clone -b ' + remote.runtime.branchName + ' ' + remote.runtime.repository + ' repo');
	});
});

/**
 * Deploys a new version of the code pulling it from the git repository
 *
 * Usage:
 * > fly deploy[:remote]
 */
plan.remote('deploy', function(remote) {
	remote.hostname();

	remote.with('cd ' + remote.runtime.webRoot, function() {
		remote.sudo('cd repo && git pull');
		var command = remote.exec('date +%s.%N');
		var versionId = command.stdout.trim();
		var versionFolder = 'versions/' + versionId
		
		remote.sudo('cp -R repo ' + versionFolder);
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' ' + versionFolder);
		remote.sudo('ln -fsn ' + versionFolder + ' current');
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' current');

		if (remote.runtime.maxDeploys > 0) {
			remote.log('Cleaning up old deploys...');
			remote.sudo('rm -rf `ls -1dt versions/* | tail -n +' + (remote.runtime.maxDeploys+1) + '`');
		}

		remote.log('Successfully deployied in ' + versionFolder);
		remote.log('To rollback to the previous version run "fly rollback:production"');
	});
});

/**
 * Rollbacks to the previous deployed version (if any)
 *
 * Usage
 * > fly rollback[:remote]
 */
plan.remote('rollback', function(remote) {
	remote.hostname();

	remote.with('cd ' + remote.runtime.webRoot, function() {
		var command = remote.exec('ls -1dt versions/* | head -n 2');
		var versions = command.stdout.trim().split('\n');

		if (versions.length < 2) {
			return remote.log('No version to rollback to');
		}

		var lastVersion = versions[0];
		var previousVersion = versions[1];

		remote.log('Rolling back from ' + lastVersion + ' to ' + previousVersion);

		remote.sudo('ln -fsn ' + previousVersion + ' current');
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' current');

		remote.sudo('rm -rf ' + lastVersion);
	});
});

It’s a lot of code, let’s analyze it piece by piece.

The remote configuration

The remote configuration defines our production environment on our server and tells Flightplan how to connect to it and what the specific options for the server are. In more advanced setups you can also specify an array of servers to which the deploy will be carried simultaneously.

plan.target('production', {
  host: 'example.com',
  username: 'someuser',
  password: 'somepassword',
  agent: process.env.SSH_AUTH_SOCK,

  webRoot: '/var/www/mywebsite',
  ownerUser: 'www-data',
  repository: 'https://github.com/someuser/example-com.git',
  branchName: 'master',
  maxDeploys: 10
});

As you can see there are some configurations that you will need to change, like: host, username, password (probably not required), webRoot and repository.

The setup command

The setup command is a helper command that you can launch once to configure the remote machine folders.

plan.remote('setup', function(remote) {
	remote.hostname();

	remote.sudo('mkdir -p ' + remote.runtime.webRoot);
	remote.with('cd ' + remote.runtime.webRoot, function() {
		remote.sudo('mkdir versions');
		remote.sudo('git clone -b ' + remote.runtime.branchName + ' ' + remote.runtime.repository + ' repo');
	});
});

The command remote.hostname() is a debug function that prints the hostname of the server the script is currently in. I recommend to use it, because if you need to switch to a multi-server environment it will be very useful to follow the output of all the servers.

In the next lines we first create our web root folder, then inside that folder we create the versions folder and we clone our repository into the folder repo.

The deploy command

The deploy command is, of course, the most important one, let’s review its code:

plan.remote('deploy', function(remote) {
	remote.hostname();

	remote.with('cd ' + remote.runtime.webRoot, function() {
		remote.sudo('cd repo && git pull');
		var command = remote.exec('date +%s.%N');
		var versionId = command.stdout.trim();
		var versionFolder = 'versions/' + versionId
		
		remote.sudo('cp -R repo ' + versionFolder);
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' ' + versionFolder);
		remote.sudo('ln -fsn ' + versionFolder + ' current');
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' current');

		if (remote.runtime.maxDeploys > 0) {
			remote.log('Cleaning up old deploys...');
			remote.sudo('rm -rf `ls -1dt versions/* | tail -n +' + (remote.runtime.maxDeploys+1) + '`');
		}

		remote.log('Successfully deployied in ' + versionFolder);
		remote.log('To rollback to the previous version run "fly rollback:production"');
	});
});

The first thing we do is to update the repo folder pulling all the new stuff from our repository. Then we execute the date +%s.%N bash command that prints out the current timestamp in seconds and milliseconds (e.g. 1441459969.874386446). This will be the id of our new deploy. At this stage, we simply copy the repo folder inside the versions folder using the new version id as folder name (e.g. versions/1441459969.874386446). To finish the process we symlink this new folder to the current folder. We also take care to assign these files to the user www-data so that nginx will be able to read these files.

Then we clean up the old deploys using a handy bash short-liner command. The command lists and removes all the folders in the versions directory skipping the first n, where n is the number of deploys that we want to keep.

The rollback command

The rollback command allows us to restore the previous version and to delete the newest one, it’s like a step back in time.

plan.remote('rollback', function(remote) {
	remote.hostname();

	remote.with('cd ' + remote.runtime.webRoot, function() {
		var command = remote.exec('ls -1dt versions/* | head -n 2');
		var versions = command.stdout.trim().split('\n');

		if (versions.length < 2) {
			return remote.log('No version to rollback to');
		}

		var lastVersion = versions[0];
		var previousVersion = versions[1];

		remote.log('Rolling back from ' + lastVersion + ' to ' + previousVersion);

		remote.sudo('ln -fsn ' + previousVersion + ' current');
		remote.sudo('chown -R ' + remote.runtime.ownerUser + ':' + remote.runtime.ownerUser + ' current');

		remote.sudo('rm -rf ' + lastVersion);
	});
});

What we do here is to use another bash short-liner to get the last 2 deployed versions inside the versions folder. The first one is the newest one, the second is the one we want to rollback to. Then we simply symlink the second to the current folder and delete the first one.

Make the first deploy (local)

Ok, now that we have all our Flightplan commands ready we can launch off the first deploy.

For the first time we need to run:

fly setup:production

to set up all the folders on our server, then we can launch our first deploy with

fly deploy:production

If we have done everything correctly our deploy should be successful and show a green dot! But wait… why our server still shows the default nginx page? Oh well, we still need to configure nginx to use our current folder!

Configure the web server (remote)

Get back to you ssh console and run:

sudo nano /etc/nginx/sites-available/default

This will open the nginx configuration file with the nano editor. Move the cursor down until you see a server block definition and look for the root property. There should be the default nginx page path in there, just replace it with the path of your current folder (e.g. root /var/www/mywebsite/current;). Then you have to save the file with ctrl + o and exit with ctrl + x.

The last step to do is to force nginx to reload the new configuration:

sudo service nginx reload

Now get back to our website and refresh! Tadá! Our web server is now serving our new website! Cheer up!

Updating the website (local)

Ok now maybe it’s time to realize that our new website it’s not so amazing, maybe it would be nice to add an image and change the background color. You can grab a very nice image from here. Let’s update our HTML file:

<!doctype html>
<html>
 <head>
 <meta charset="utf-8">
 <title>My wonderful website</title>
 <link rel="stylesheet" href="css/style.css">
 </head>
 <body>
 <h1>Welcome to my wonderful website</h1>
 <p>You will never find another place as cool as this</p>
 <p><img src="img/bridge.jpg" alt="A nice picture of The Bridge"/></p>
 </body>
</html>

We basically just added the paragraph with the image, but notice we also moved our stylesheet into its own css folder! Ok now just add the following block of CSS at the end of our stylesheet to change the background color:

css
 body {
 background: #97d2fc;
 }

And here we have it!

website preview 2

I know, I know… it looks like one of that websites from the early ’90s, but let’s pretend it’s wonderful and we want to deploy this new version for now.

So first of all we need to commit our modifications. It’s always a good idea to launch a git status before committing to have a clear idea of what has changed. That’s the output we should get:

git-status

There are some things to review here. First of all we moved our css file to the folder css so git thinks we deleted it. We also changed our file index.html, then there are some new files and folders that we need to add to the repository.

But it’s a good idea to not add the node_modules folder because we don’t want it on our website, it’s just there because it contains all the needed dependencies to run Flightplan on our local machine. Thus, the best approach is to put it onto the git ignore list. To do so we need to create a new file called .gitignore:

# .gitignore
node_modules/

If you run git status again you can see that the output changed a bit:

git-status-2

The node_modules folder disappeared and the new .gitignore file is waiting to be added to the repository.

To add all these new files and folders to our repository we need to launch the following git command:

git add --all

Let’s launch git status again to see what’s happened:

git-status-3

Now everything is green and git also recognized that we moved the css file to the new css folder. Great, it seems that we have added all the files that we need and we are now ready to commit the changes to generate a new version in the local repository:

git commit -am "Added picture and background color"

And then we can send this new version to the remote repository with:

git push

Now it’s time to run our deploy script to update our remote server:

fly deploy:production

We should see a bunch of lines of output and then a green dot that confirms that our deploy went fine! Great, let’s now refresh our website page in the browser and see that our new version is online!

Rolling back (local)

We are just cheering up for the update but after a couple of minutes one of our friends call us to say:

“Man, what’s happened to your website? It looks uglier than ever, I really loved the previous version!”

So yes, maybe we realize as well that the previous version had that kind of fascinating minimalism and that it’s better to rollback. Our Flightplan configuration it’s here to support us and make things simple, just run:

fly rollback:production

Let’s refresh your browser and tell our friend he was right!

Maybe it’s time to work on a better new version, but now you know how to do it on your own 😉

Wrapping it up!

In this article we learned a lot of useful things about how to keep our website versioned and how to deploy it easily. We used a bunch of interesting tools like git and Flightplan.

The article was focused on explaining the basics of these tools. You might have a dynamic website written in Php or NodeJs rather than a static site so you might need to add more steps to configure your remote server and your deploy process. Anyways, it shouldn’t be too hard now that you have a boilerplate to start from.

I hope the article was useful, I really hope to hear your opinions in the comments below!

About the Author:

Luciano MamminoLuciano is a Software Engineer born in 1987, the same year that the Nintendo released “Super Mario Bros” in Europe, which, by chance is his favourite videogame!

He is passionate about code, the web, smart apps and everything that’s creative like music, art and design. As web developer his experience has been mostly with PHP and Symfony2, even if he recently fell in love with Javascript, NodeJS and Docker. In his (scarce) free time he writes on his personal blog at loige.co.

If you like this article, give it some sharing ❤️