Migrating from Heroku to AWS

Last updated April 5th, 2018

In this tutorial, I tackled two major goals:

  1. give my personal apps a more professional UX
  2. reduce my overall hosting cost by 50%

I have been using the free tier of Heroku to serve up demo apps and create tutorial sandboxes. It's a great service, easy to use and free, but it does come with a lengthy lag time on initial page load (about 7 seconds). Thats a looooong time by anyone's standards. With a 7 second load time, according to akamai.com and kissmetrics, more than 25% of users will abandon your page well before your first div even shows up. Rather than simply upgrading to the paid tier of Heroku, I wanted to explore my options and learn some useful skills in the process.

What's more, I also have a hosted blog on Ghost. It's an excellent platform, but it's a bit pricey. Fortunately, they offer their software open source and provide a great tutorial on getting it up and running with Node and MySQL. You simply need somewhere to host it.

By parting ways with my hosted blog and serving up several resources from one server, I can provide a better UX for my personal apps and save a few bucks at the same time. This post organizes some of the best tutorials on the web to get this done quickly and securely.

This requires several different technologies working together to accomplish the goal:

Tech Purpose
EC2 provide cheap, reliable cloud computing power
Ubuntu the operating system that handles running our programs
Docker an isolation layer to provide a consistent execution environment
Nginx handle requests in a robust and secure way
Certbot serve up SSL/HTTPS secured web applications, and in turn, increase SSO (search engine optimization)
Ghost provide a simple blog with GUI and persistance
React allow for fast, composable web applications



  • Host personal projects, portfolio site, blog -> cheaply and without loading lag time
  • Get acquainted with Nginx
  • Serve HTTPS encrypted sites
  • Dockerize React

Technologies Used

  • Amazon EC2
  • Ubuntu
  • Nginx
  • React
  • Let's Encrypt and Certbot (for SSL)
  • Docker
  • Ghost Blog Platform


After completing this tutorial, you will be able to:

  • Set up an EC2 instance
  • Set up Nginx
  • Configure your DNS with sub-domains
  • Set up the Ghost blog platform on an EC2 instance
  • Dockerize a static React app
  • Serve a static site
  • Configure SSL with Let's Encrypt and Certbot

The Finances

Current Hosted Solutions (No Lag Time)

Resource Service Price / Month Info
Blog Ghost Pro $19 https://ghost.org/pricing
Personal Apps Heroku Hobby $7/app https://www.heroku.com/pricing

Self Hosted Options

Resource Service Price / Month Info
Blog and Apps AWS EC2 T2 Micro (1GB Memory) ~$10 https://aws.amazon.com/ec2/pricing/on-demand
Blog and Apps Linode (1GB Memory) $5 https://www.linode.com/pricing/
Blog and Apps Digital Ocean (1GB Memory) $5 https://www.digitalocean.com/pricing

So with a hosted solution, for one blog and one app, I would be paying $26 per month and that would go up $7/month with each new app. Per year, thats $312 + $84 per additional app. With a little bit of leg work outlined in this post, I am hosting multiple apps and a blog for less than $10/month.

I decided to go with the AWS solution. While it is more expensive, it is a super popular enterprise technology that I want to become more familiar with.


A BIG THANKS to all the folks who authored any of the referenced material. Much of this post consists of links and snippets of resources that proved to work well and includes the slight modifications needed along the way to suite my needs.

Thank you, as well, for reading. Let's get to it!

EC2 setup

Here is how to create a new EC2 instance.

Resource: https://www.nginx.com/blog/setting-up-nginx

All you really need is the above tutorial to be on your way with setting up an EC2 instance and installing Nginx. I stopped after the EC2 creation since Nginx gets installed during the Ghost blog platform setup.

Elastic IP

Resource: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/elastic-ip-addresses-eip.html

Further down the road, you are going to point your DNS (domain name system) at your EC2 instance's public IP address. That means you don't want it to change for any reason (for example, stopping and starting the instance). There are two ways to accomplish this:

  1. activate the default VPC (virtual private cloud) in the AWS account
  2. assign an Elastic IP address

Both options provide a free static IP address. In this tutorial, I went with the Elastic IP to accomplish this goal as it was really straightforward to add to my server after having already set it up.

Follow the steps in the above resource to create an elastic IP address and associate it with your EC2 instance.


Resource: https://www.digitalocean.com/community/tutorials/initial-server-setup-with-ubuntu-16-04

I followed this tutorial to the 'T'...worked like a charm. You'll set up your own super user with its own SSH key and create a firewall restricting incoming traffic to only allow SSH.

In a minute you'll open up both HTTP and HTTPS for requests.

DNS Configuration

I use Name.com for my DNS hosting because they have a decent UI and are local to Denver (where I reside). I already own petej.org and have been pointing it to a github pages hosted static site. I decided to set up a sub-domain for the blog -- blog.petej.org -- using A records to point to my EC2 instance's public IP address. I created two A records, one to handle the www prefix and another to handle the bare URL:


Now via the command line, use the dig utility to check to see if the new A record is working. This can be done from your local machine or the EC2 instance:

$ dig A blog.petej.org

; <<>> DiG 9.9.7-P3 <<>> A blog.petej.org
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 44050
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

; EDNS: version: 0, flags:; udp: 512
;blog.petej.org.            IN  A

blog.petej.org.     300 IN  A

;; Query time: 76 msec
;; WHEN: Sat Jan 27 10:13:50 MST 2018
;; MSG SIZE  rcvd: 59

Note: The A records take effect nearly instantaneously, but can take up to an hour to resolve any caching from a previous use of this URL. So if you already had your domain name set up and working, this may take a little while.

Nice: domain --> √. Now you need to get your EC2 instance serving up some content!

Ghost Blog Platform

Resource: https://docs.ghost.org/install/ubuntu/

Another great tutorial. I followed it every step of the way and it was golden. There are some steps that we have already covered above, such as the best practices of setting up an Ubuntu instance, so you can skip those. Be sure to start from the Update Packages section (under Server Setup).

Note: Follow this setup exactly in order. My first time around I neglected to set a user for the MySQL database and ended up having to remove Ghost from the machine, reinstall, and start from the beginning.

After stepping through the Ghost install process, you should now have a blog up and running at your domain name - check it out in the browser!

Midway recap

What have you accomplished?

  • Ubuntu server up and running
  • SSH access into our server
  • Ghost platform installed
  • Nginx handling incoming traffic
  • Self hosted blog, up!

So whats next?

You are now going to:

  1. Install git and set up SSH access to your GitHub account
  2. Dockerize a static React app
  3. Set up Docker on the EC2 instance
  4. Configure the Nginx reverse proxy layer to route traffic to your React app
  5. Associate SSL certificates with your blog and react app so they can be served over HTTPS


Gotta have git

Install git on the EC2 instance:

$ sudo apt-get install git

Create a new SSH key specifically for GitHub access: https://help.github.com/articles/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent

Because you set up a user for the Ubuntu server earlier, the /root directory and your ~ directory (user's home directory) are different. To account for that, on the ssh-add step do this instead:

cp /root/.ssh/id_rsa ~/.ssh/id_rsa
cd ~/.ssh
$ sudo cat ~/.ssh/id_rsa

Copy the output and add it to GitHub as a new SSH key as detailed in the below link.

Start with step 2 --> https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account

You are all set up to git. Clone and then push a commit to a repo to make sure everything is wired up correctly.

Static React App

Resource: https://medium.com/ai2-blog/dockerizing-a-react-application-3563688a2378

Once you have your React app running locally with Docker, push the image up to Docker Hub:

You will need a Docker Hub account --> https://hub.docker.com

$ docker login
$ docker tag <image-name> <username>/<image-name>:<tag-name>
$ docker push <username>/<image-name>

This will take a while. About 5 min. Coffee break...

And we're back. Go ahead and log in to GitHub and make sure that your image has been uploaded.

Now back to your EC2 instance. SSH into it.

Install docker:

$ sudo apt install docker.io

Pull down the Docker image locally that you recently pushed up:

$ sudo docker pull <username>/<image-name>

Get the image id and use it to fire up the app:

$ sudo docker images
# Copy the image ID
$ sudo docker run -d -it -p 5000:5000 <image-id>

Now that you have the React app running, let's expose it to the world by setting up the Nginx config.

Nginx setup for React app

Resource: https://www.digitalocean.com/community/tutorials/how-to-install-nginx-on-ubuntu-16-04

Note: Instead of using /etc/nginx/sites-available/default like the tutorial suggests, I made one specific for the URL (better practice and more flexible going forward) --> circle-grid.petej.org.conf file, leaving the default file completely alone.

We also need to set up a symlink:

$ sudo ln -s /etc/nginx/sites-available/circle-grid.petej.org.conf /etc/nginx/sites-enabled/

Note: Why the symlink? As you can see if you take a look in /etc/nginx/nginx.conf, only the files in the /sites-enabled are being taken into account. The symlink will take care of this for us by representing this file in the sites_available file making it discoverable by Nginx. If you've used Apache before you will be familiar with this pattern. You can also remove symlinks just like you would remove a file: rm ./path/to/symlink.

More about 'symlinks': http://manpages.ubuntu.com/manpages/xenial/en/man7/symlink.7.html

Let's Encrypt with Certbot

Resource: https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04

Now to be sure that Certbot configured a cron job to auto renew your certificates run this command:

$ ls /etc/cron.d/

If there is a certbot file in there, you are good go.

If not, follow these steps:

  1. Test the renewal process manually:

    $ sudo certbot renew --dry-run

  2. If that is successful, then:

    $ nano /etc/cron.d/certbot

  3. Add this line to the file:

    0 */12 * * * root test -x /usr/bin/certbot -a \! -d /run/systemd/system && perl -e 'sleep int(rand(3600))' && certbot -q renew

  4. Save it, all done.

You have now configured a task to run every 12 hours that will upgrade any certs that are within 30 days of expiration.


You should now be able to:

  • Set up an EC2 instance
  • Set up Nginx
  • Configure your DNS with sub-domains
  • Set up a Ghost blog platform
  • Dockerize a React app
  • Serve a static React app
  • Configure SSL --> Let's Encrypt and Certbot

I hope this was a helpful collection of links and tutorials to get you off the ground with a personal app server. Feel free to contact me (pete dot topleft at gmail dot com) with any questions or comments.

Thanks for reading.

Pete Jeffryes

Pete Jeffryes

Pete a software developer who loves to build tools. He has a lot of experience with language learning: music, Mandarin Chinese, and code. He is fascinated by patterns/systems and their manipulation.

Share this tutorial

Featured Course

The Definitive Guide to Celery and Flask

Learn how to add Celery to a Flask application to provide asynchronous task processing.

Featured Course

The Definitive Guide to Celery and Flask

Learn how to add Celery to a Flask application to provide asynchronous task processing.