Note: Even though this article was written in the context of Node.js/MERN apps. If you are looking to deploy your non-Node.js apps to AWS, you can still refer to sections of this article for NGINX, custom domain, SSL setup, etc.

Introduction

In this article, I would like to outline the configuration of a production environment and deploying a Node.js/MERN application onto AWS, served using an SSL enabled custom domain.

Recently, I deployed multiple apps onto AWS, I was slightly overwhelmed as I was referring to multiple sources of information to get things up and running. I felt the need for a one-stop in-depth guide to help understand the process. It struck me then to write a detailed article and some reference cheatsheets for the same.

I will only be covering the deployment section in this article, I will update or add another post later regarding further optimizations after the entire deployment process.

Buckle up. This is gonna be a long and detailed article.

Project Structure

For this article, I’m going to reference my MERN SPA application called Shrynk.js.

Live: https://shrynk.jagankaartik.live

This is just a transient folder structure of the application. This is a monorepo, which means that there is a separate React client folder and an Express server folder.

.
├── Makefile
├── app
└── server

What I’ve used for deployment ?
Cloud Service Provider AWS EC2
Managed DNS AWS Route53
AMI Instance Type Ubuntu 20.04 LTS
Source Code Management Github
Reverse Proxy NGINX
Process Manager PM2 for Node.js
Custom Domain Provider Name.com

Steps

1. Creating a Ubuntu Linux Instance using AWS EC2
2. Install Node/NPM
3. Clone your project from Github/Gitlab/Bitbucket
4. Install all the dependencies
5. Setting up PM2 Process Manager
6. Setting up NGINX
7. Setting up Custom Domain for our EC2 Instance
  • Setting up and managing DNS for a Custom Domain using domain name provider’s basic DNS
  • Setting up a Hosted zone and Managing DNS for a Custom Domain using AWS Route53
8. Adding SSL with LetsEncrypt

Creating a Ubuntu Linux Instance using AWS EC2

Fairly straight forward, if you don’t have an AWS account. Create a free-tier account and continue.

Budgeting

This is a critical step, particularly if you are not using a free tier account. Always set a budget and threshold for billing alerts to prevent unexpected invoices, etc. Even if you are on a free-tier account, set a budget.

Set budgets and remember prevention is better than cure.

Region selection

By default

Amazon EC2 is hosted in multiple locations worldwide. These locations are composed of Regions, Availability Zones, Local Zones, AWS Outposts, and Wavelength Zones. Each Region is a separate geographic area.

The default region is US East (Ohio) (us-east-2)

Change your default region depending on your application’s use case. This is the location of the data center where our Linux instance resides.

If you have a small app, that you built as a hobby project. Choose the AWS Region that is closest to your location geographically. Choose Asia Pacific (Mumbai) if you are in South India, Asia Pacific (Sydney) if you are in Australia, and so on.

When developing an application that must serve users from a specific geographical area with almost no latency, we must make the appropriate choices (Region Selection, CDNs, etc). However, this is beyond the scope of this blog.

Creation of Instance

On the EC2 Dashboard, select the Launch Instance option.

Choose Amazon Machine Image (AMI)

Choose the Amazon Machine Image, for this build we choose Ubuntu 20.04 LTS for 64bit (x86) arch.

Choose the Instance Type

Amazon EC2 provides a wide choice of instance types optimized to fit distinctive use cases. Instance types comprise varying combinations of CPU, memory, storage, and networking capacity.

Here t2.micro qualifies for free-tier, also this should be sufficient for small web apps.

Instance Configuration

Leave the defaults alone; this section essentially asks for the number of instances (for scaling up the application), custom virtual private cloud (VPC) settings, IAM roles, and so on.

Storage

The defaults should suffice.

Security Groups

Add rules to allow traffic from HTTP and HTTPS.

Reviewing the Instance

In this step, we can see our AMI, instance type, security groups, storage and other info. Hit Launch to create the instance.

Upon Launch, we have to create a key-pair which is to be used as a private key for SSHing into our created instance. Create and store this key-pair safely.

A GIF walkthrough of the creation process

Connecting to the Instance

Go to EC2 > Instances > Instance-ID > Connect to Instance -> SSH Client

  • chmod 400 - To protect a file against accidental overwriting.
chmod 400 "your-key.pem"

ssh -i /path/my-key-pair.pem my-instance-user-name@my-instance-public-dns-name

# Example
ssh -i "you-key.pem" ubuntu@ec2-xx-xx-xxx-xx.region.compute.amazonaws.com

Install Node/NPM

After SSHing to Instance, first lets install Node.js.

If you want the latest version Node v16.x use the below script, other versions of Node are generally in the form of setup_v.x here v16.x so setup_16.x.

curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version

Check out the NodeSource link for detailed installation instructions on other platforms/versions.

Link to NodeSource for Node.js Binary Distributions: https://github.com/nodesource/distributions/blob/master/README.md

Clone your project from Github/Bitbucket/Gitlab

First, connect your account via SSH and clone your repository. Find below the links for connecting SSH via Github/Gitlab/Bitbucket

Links for connecting SSH via Github/Gitlab/Bitbucket

Provider Links
Github https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh
Gitlab https://docs.gitlab.com/ee/ssh/
Bitbucket https://support.atlassian.com/bitbucket-cloud/docs/set-up-an-ssh-key

Once SSH keys are generated and added to your Github/Bitbucket/Gitlab accounts. The next step is to clone your repositories to your instance.

Install all the associated Dependencies

Go to your folder containing package.json and run,

# If using yarn, install yarn too.
# npm i -g yarn
yarn install

# Tests if any
yarn test
Makefile (simple hack…)

In my application, I have created a simple Makefile where I’ve specified the nessessary installation and build commands. Running make install will install all the required dependencies and make build will build the application.

Contents of the makefile.

install:
		cd app && yarn install
		cd server && yarn install

build:
		cd app && yarn build
		cd server && yarn build

A makefile is ideal in this scenario, as it automates a part of the installation process rather than going manually to the folder and running the commands. Also being a monorepo containing package.json file in both client and server, this is a better approach.

# To install all dependencies, go to root directory (Makefile present in root)
make install

# To build the React App & Typescript Express App
make build

Setup PM2 Process Manager for Node

Why PM2 ?

PM2 is a production process manager for Node.js applications with a built-in load balancer. It allows you to keep applications alive forever, to reload them without downtime and to facilitate common system admin tasks. - https://pm2.keymetrics.io/

Usually on dev or local environments, a node application runs actively on terminal, only quits if we manually hit cmd+c or ctrl+c. Running your application on production normally using commands like yarn dist/index or npm run start would definitely work. However, if you’ve noticed sometimes when things so wrong (errors) the application abruptly stops. If this happens in production, it’s gonna cause a lot of chaos.

Simple Context :)

My application is a URL-shortening service. Let’s picture this scenario with some scale. If the service goes down, users will be unable to create and redirect URLs. Which is not at all optimal if you operate for a huge scale of users.

In real-world scenarios, this leads to a lot of chaos, as our “service” may be a part of any other critical services. Eg. Authentication Provider, SAAS platform, etc.

> Note: Just an easy-to-understand example quoted above, how it happens in
 real-life scenarios is far more complex. (again out of scope)

To solve such issues, we use PM2 as it runs a node application in the background. It provides features like monitoring, load-balancing, keep various versions up to and running, watch and reload, etc.

Refer: https://pm2.keymetrics.io/

Install PM2 using NPM or Yarn
sudo npm i pm2 -g
# or
sudo yarn add global pm2
Go to your app's entry file
pm2 start app.js # (or whatever your file name)

PM2 Commands for Reference

Shows detailed metrics about Memory, Process Info, Uptime, Log paths etc

pm2 show app

Shows the application status (online,stopped) with a overview of CPU & Ram usage

pm2 status

Restarts the app

pm2 restart app

Stops the app

pm2 stop app

Show Logs

pm2 logs

FLushes all logs

pm2 flush

To make sure app starts when reboot

pm2 startup

If you have a script called “start” in package.json that runs your application in production mode. Example node dist/index instead of nodemon. You can use the below command to use your npm/yarn start script via PM2.

# For running your npm.yarn start script in package.json
pm2 start yarn -- start
pm2 start npm -- start

# With name parameter to identify your application
pm2 start yarn --name "app name" -- start
pm2 start npm --name "app name" -- start

Warning ⚠️  -  We are going to alter Security Groups to add the corresponding PORT X of your application (for just verification purposes). After this, we need to remove PORT X from security groups.

Go to your EC2 Instance Summary, In the security tab, you will find the “security group” associated with your EC2 Instance.

Clicking that link, now you will be presented with the Security Groups Dashboard.

Now, click edit Inbound Rules and add Custom TCP & Specify PORT from Anywhere

Now, it should look something similar to this, based on your PORT.

Now, you will be able to access your site at EC2 Instance's Public IP Address:PORT eg: 231.112.123.12:8000

In the above step, we accessed our main application via PORT:8000. In a production environment, we need to avoid this by listening only at 80 or 443.

  • NGINX should be set up as a reverse proxy to proxy client requests from 80 or 443 to 8000 on the instance.
  • Security groups should be set up to only allow access via 80,443 blocking all other ports.

Warning ⚠️ - Only remove your Custom TCP & PORT, do not remove any other types of rules.

Now, remove the above added Custom TCP & PORT from inbound rules.

Setting up NGINX

Based on the assumption that we are using this EC2 instance just for one particular Node.js/MERN app and not having multiple different Node.js/MERN apps.

We will be editing the “default config” if you want to deploy multiple apps on a single instance. I will follow up with a post and update a link to it.

Nginx is a high-performance web server and a reverse proxy server.

A web server is a software that serves web content through the HTTP protocol. Content can be static or dynamic.

A reverse proxy is a service that takes a client request, sends the request to one or more proxied servers, fetches the response, and delivers the server’s response to the client.

  • Load Balancing
  • Backend Routing
  • Caching

Installation

sudo apt install nginx

After installation go to Port 80 of your EC2 Instance’s public IP, which is basically just your IP address without PORT. If you see the NGINX welcome page, NGINX is successfully installed.

Now, we need to change the default configuration.

cd /etc/nginx/sites-available/default

sudo vim default

This is a starting section of the syntax-highlighted NGINX default config,

We need to modify the below section,

server_name _;
location / {
  # First attempt to serve request as file, then
  # as directory, then fall back to displaying a 404.
  try_files $uri $uri/ =404;
 }

Add the following snippet replacing the section in the default config.

  server_name yourdomain.com www.yourdomain.com;

  location / {
        proxy_pass http://localhost:8000; # App's port
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

Explanation of the above Code Snippet

In the default NGINX configuration, nginx listens on port 80 as indicated by the line listen 80 of the server block.

  • proxy_pass forwards all requests to the given proxy url. Here the proxy url is http://localhost:8000.

  • proxy_http_version sets the HTTP protocol for proxying. By default, version 1.0 is used. For Websockets and keepalive connections you need to use the version 1.1.

  • proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection 'upgrade' header fields are required to switch the connection to an enhanced protocol if available.

  • proxy_set_header Host $host is used to set the host to $proxy_host.

  • proxy_cache_bypass $http_upgrade we include this directive to not send cached responses to clients.

Refer link for more detailed info: http://nginx.org/en/docs/http/ngx_http_proxy_module.html

After adding the above code snippet, check NGINX config for errors and restart nginx.

# Run this to check NGINX config
sudo nginx -t
Output

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Restart NGINX service

sudo service nginx restart

Now to check NGINX status,

sudo systemctl status nginx
If you get this output then success!

● nginx.service - A high performance web server and a reverse proxy server
Loaded: loaded (/lib/systemd/system/nginx.service; enabled; vendor preset: enabled)
Active: active (running) since DAY YYYY-MM-DD HH:M:SS UTC; X days ago
Docs: man:nginx(8)
Main PID: xxxx (nginx)
Tasks: 2 (limit: 1160)
Memory: X M
CGroup: /system.slice/nginx.service
├─ xxxx nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
└─xxxx nginx: worker process

Now, You will be able to view your app on Instance’s Public IP with no port (port 80) EC2 Instance’s Public IP Address eg: 231.112.123.12

Setting up Custom Domain for our EC2 Instance

Based on the assumption that you have a custom domain. I will give an overview of using both the Basic & Route53 DNS.

Why Basic DNS?

In the context of small-scale web apps, hobby projects that you want to showcase using your custom domain. Ideally use the free DNS service that is provided with your custom domain name providers, such as Namecheap or Name.com

Why Route53 DNS?

In the context of large apps, that you want to geographically scale :) pay extra for a “premium” DNS provider to get better lookup speeds (the time it takes to redirect a user) and faster propagation times (how long it takes for changes to your DNS configuration to take effect). Eg. Route53, Cloudflare, etc.  Route53 is an advanced managed DNS provided by AWS, it integrates well with AWS’s services. There are tons of features for Route53, it acts as a load balancer at the DNS level, can be used for advanced A/B testing, integrates with ELB, etc. Detailed use cases, comparisons of Route53 are far out of the scope of this blog.

Setting up and managing DNS for a Custom Domain using domain name provider’s basic DNS

All you have to do is create two A records in the DNS records section. An A record points a hostname to an IP address.

That’s it process complete, depending on your provider. It might take some time for the actual DNS propagation.

You can use nslookup or dig to check dns response. Sample nslookup output given below.

nslookup yourdomain.com

# Output
Server:		192.168.0.1
Address:	192.168.0.1#53

Non-authoritative answer:
Name:	yourdomain.com
Address: xx.xx.xx.xx # AWS EC2 Public IP

If the Non-authoritative answer’s address points to EC2’s Public IP. Boom!

After this step, your app will be accessible via http://yourdomain.com

Skip to the last section for SSL configuration.

Setting up a Hosted zone and Managing DNS for a Custom Domain using Route53

Now we need to set up a Custom Domain to point to our instance and complete the SSL setup.

Steps

  1. Create a Hosted Zone for your domain.
  2. Create two A records for your domain.
  3. Add your Hosted Zone’s AWS Nameservers to your custom domain provider’s nameserver section.

Hosted Zone

A hosted zone is a container for records, and records contain information about how you want to route traffic for a specific domain, such as example.com, and its subdomains (acme.example.com, zenith.example.com) - AWS Docs

Note: Hosted Zone doesn’t come under a free-tier, hosted zone is paid.

It costs about,

  • $0.50 per hosted zone / month for the first 25 hosted zones
  • $0.10 per hosted zone / month for additional hosted zones

Queries costs about,

  • $0.40 per million queries – first 1 Billion queries / month
  • $0.20 per million queries – over 1 Billion queries / month

Refer: https://aws.amazon.com/route53/pricing/

Create a hosted zone

Add your domain, description, type (public here) and hit Create hosted zone

  • Public hosted zones contain records that specify how you want to route traffic on the internet.
  • Private hosted zones contain records that specify how you want to route traffic in an Amazon VPC.

Refer for more info: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/hosted-zones-working-with.html

Since we are building an application that needs to be accessible from the web, select Public Hosted Zone

In the Hosted Zone dashboard, we can see the Record, Type, Value/Route traffic to options.

We need to create two A records, to route all traffic hitting yourdomain.com and www.yourdomain.com to the public ip of the created EC2 Instance.

Creating @ record

Creating WWW record

Now, the hosted zone dashboard should look like this.

Updating Name Server Records (NS)

We need to add the NS Record values to our custom domain provider’s nameserver section.

Find the name server records associated with your domain.

  • ns-xxx.awsdns-xx.org
  • ns-xxx.awsdns-xx.com
  • ns-xxx.awsdns-xx.co.uk
  • ns-xxx.awsdns-xx.net

Add them to your domain name provider’s Nameserver section.

Please Note

Nameservers are the first thing domain names look to when they need to know where the content of a site is located. Nameservers dictate the DNS, which means you have to set up the DNS records with your nameserver provider.

If you have no other major DNS records. (skip below Warning)

NOTE: If you have existing DNS records managed by your custom domain provider, if you update the given Nameserver records to AWS’s Nameserver records, your other existing DNS (A, CNAME, etc) records will not work. You will have to update them on Route53’s hosted zone dashboard in order for them to work, also this can cause some disruption the first time as DNS progression takes time.

If you are on a brand new domain or a domain without other DNS records, then no need to worry. Proceed

Name.com

  • You will need to remove all nsx.name.com records and add the above aws nsrecords associated with your domain.

Refer: https://www.name.com/support/articles/205934547-Changing-nameservers-for-DNS-management

Namecheap

Similarly, add the nameservers to your domain name provider’s nameserver section.

Now, nameserver & DNS progression will take some time. You might have to wait up to 24 or above hours depending on your provider.

Mostly it happens in 1 hour or so, do check every hour. You can use nslookup or dig to check dns response. Sample nslookup and dig output given below.

nslookup yourdomain.com

# Output
Server:		192.168.0.1
Address:	192.168.0.1#53

Non-authoritative answer:
Name:	yourdomain.com
Address: xx.xx.xx.xx # AWS EC2 Public IP

dig yourdomain.com

# Output
; <<>> DiG 9.10.6 <<>> yourdomain.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: xxxxx
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;yourdomain.com. IN A
;; ANSWER SECTION:
yourdomain.com. 3600 IN A xx.xx.xx.xx # AWS EC2 Public IP
;; Query time: 75 msec
;; SERVER: 192.168.0.1#53(192.168.0.1)
;; WHEN: Fri Jun 11 23:29:12 IST 2021
;; MSG SIZE  rcvd: 69

After this step, your app will be accessible via http://yourdomain.com

Add SSL with LetsEncrypt for HTTPs

SSL, or Secure Sockets Layer, is an encryption based Internet security protocol. A website that implements SSL/TLS has “HTTPS” in its URL instead of “HTTP.”

sudo add-apt-repository ppa:certbot/certbot
sudo apt-get update
sudo apt-get install python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com

Now while certbot configures your HTTPS settings, you will be prompted to enter your email address and agree to the Let’s Encrypt terms of service.

Once the process is over, you will be again prompted to select the Redirect/No-Redirect option, select the “Redirect” option. This will ensure all “HTTP” traffic to re-directed to “HTTPS”.

You will see the,

Congratulations! You have successfully enabled https://yourdomain.com and https://www.yourdomain.com

Now,

# Check NGINX Configuration
sudo nginx -t
# Restart NGINX
sudo systemctl reload nginx
# Certificate is only valid for 90 days. Test the renewal process with
certbot renew --dry-run

Now visit https://yourdomain.com to access your MERN app with HTTPs.

Congratulations!

Pic Credits: Photo by SpaceX