NestJS AWS Deployment Handbook Part 3: Deploying to EC2 instance with Github Action

Caroline Potts
7 min readMay 6, 2024

This is Part 3 in a series on deploying a NestJS backend to AWS for very low cost. Start with Part 1 and Part 2 if you haven’t read them yet. Check out Part 4 next! We will create a basic setup that is quick and easy to setup and use! It is not meant to be a permanent solution for an app with real users, since we will end with just a single EC2 instance and a deploy that causes brief downtime.

At this point, we have run our backend app in a container locally, and we have an EC2 instance ready to run the container in the cloud. It’s time to get our container running on the EC2 instance. We’ll do that using Github actions.

What are Github Actions?

Github Actions lets you create and run workflows using the code in your repository. You can use it for free, as long as your usage is under certain limits, which you can find here.

Using Github Actions, we can easily automate all of the steps to build our Docker image and start a container from that image on our EC2 instance.

Building the Docker image using Github actions

First, you’ll need to create a new git repo for the NestJS app code you’ve been using, if you don’t already have one set up for it.

Once that’s done, we can start adding the action:

  1. At the root of your NestJS app directory, add a folder called .github/workflows. This where Github looks to find any Actions you’ve created.
  2. In that folder, add a file named deploy.yml . We will write our action in this file.
  3. Copy this code into that file:
name: Docker Build and Deploy

on:
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Build Docker Image
run: docker build -t your-username/demo-backend .
  1. Push those changes to main.
  2. In your repo in Github, click on the Actions tab.
  3. You should now see an action named Docker Build and Deploy on the left. That is the action we just wrote! Click on it.
  4. On the right, click the Run workflow dropdown. Since our changes are in main, leave the branch set to main. Click the Run workflow button.
  5. Now, a workflow run will show up (refresh the page if you’re impatient). Click on it. To see what’s happening step by step, click on the build job. You will be able to see logs for each step that happens. The action should succeed, which means that the Docker image app was successfully built within the action!

Pushing the image to Docker Hub

When we built the image locally, we were able to then run a container from that image locally because the image existed on our local machine. To make the image available on the EC2 instance, however, we need to use a container registry.

A container registry is a place where you can store container images, so that others can access them. You can think of it as the container version of what git repositories are for your code. Basically, we need to put our Docker image somewhere, so that others (the EC2 instance) can access it.

We’ll use Docker Hub for our container registry. If you don’t already have an account, create one here.

Update your deploy.yml file to look like this:

name: Docker Build and Deploy
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
# This step is new
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: docker build -t your-username/demo-backend . # replace your-username with your Docker Hub username
# This step is also new
- name: Push Docker Image
run: docker push your-username/demo-backend # replace your-username with your Docker Hub username

You’ll probably notice some new things in the two steps that were added: the secrets. In Github Actions, you can set secret values that will be pulled when the action runs, so that you don’t need to commit and expose the values of your secrets. Since we need your Docker Hub credentials to push your image to Docker Hub, we need to use secrets, so that no one can steal your account.

First, we’ll generate a new access token for Docker Hub to use as the password:

  1. In Docker Hub, click on your user icon in the top right. Click My Profile.
  2. Click Edit Profile.
  3. Click Security.
  4. Under Access Tokens, click New Access Token.
  5. Enter a description for the access token, like “Demo backend deploy Github actions token.”
  6. Leave the access permissions as Read, Write, Delete.
  7. Click Generate.
  8. Copy the password that it shows you. Note: if you close out of the modal without copying it, you’ll need to generate a new one.

Now we can add the values as a secret in Github:

  1. In Github, click on the Settings tab.
  2. On the left, click Secrets and variables and then Actions.
  3. Click New repository secret.
  4. Fill in DOCKER_PASSWORD as the name, and paste the value you copied from Docker Hub as the value. Click Add secret.
  5. Add another secret with DOCKER_USERNAME as the name, and your Docker Hub username as the value. Click Add secret.

Now we’re ready to test out our changes. Push your code to main, and then run the workflow in Github Actions again. It should succeed, and you should now see a new repository that contains the image in your in Docker Hub account!

Running the container on the EC2 instance

Now that our Github action can build our image and push it to Docker Hub, we’re ready to pull that image down on our EC2 instance and start a container from it.

Update your deploy.yml to look like this:

name: Docker Build and Deploy
on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker Image
run: docker build -t your-username/demo-backend .
- name: Push Docker Image
run: docker push your-username/demo-backend
# From here on is new
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Install SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.EC2_SSH_PRIVATE_KEY }}
- name: Deploy to EC2
run: |
ssh -o StrictHostKeyChecking=no ec2-user@ec2-ip-address.compute-1.amazonaws.com \\
'if sudo docker ps -q --filter "status=running" | grep -q .; then \\
sudo docker stop $(sudo docker ps -q --filter "status=running"); \\
fi && \\
if sudo docker ps -a -q | grep -q .; then \\
sudo docker rm $(sudo docker ps -a -q); \\
fi && \\
sudo docker pull your-username/demo-backend && \\
sudo docker run -d -p 80:80 your-username/demo-backend && \\
sudo docker image prune -f'

In the Deploy to EC2 step, replace ec2-user@ec2-ip-address.compute-1.amazonaws.com with the address you used previously to SSH onto your EC2 instance. Also replace your-username with your Docker Hub username.

Here is what each part of the Deploy to EC2 step does:

ssh -o StrictHostKeyChecking=no ec2-user@ec2-ip-address.compute-1.amazonaws.com \\
  • SSH into EC2 instance
if sudo docker ps -q --filter "status=running" | grep -q .; then \\
sudo docker stop $(sudo docker ps -q --filter "status=running"); \\
fi && \\
  • Check if there are already any running containers on the instance.
  • If there are, stop them.
if sudo docker ps -a -q | grep -q .; then \\
sudo docker rm $(sudo docker ps -a -q); \\
fi && \\
  • Check if there are any containers in any status on the instance.
  • If there are, remove them.
sudo docker pull your-username/demo-backend && \\
  • Pull the latest version of your image from Docker Hub.
sudo docker run -d -p 80:80 your-username/demo-backend && \\
  • Start a container using that image, with port 80 accessible.
sudo docker image prune -f
  • Get rid of any unused Docker images on the instance.

Since there is a new secret in what we added to the workflow, we need to add it in Github. The name should be EC2_SSH_PRIVATE_KEY, and the value should be the private key you use to SSH into the instance.

Push the changes to deploy.yml to main.

Now we’re ready to run the action again! Run the workflow in Github Actions again. It should succeed.

Testing out our deployed API

In your browser, go to http://ip-address-of-your-ec2-instance/random.

You should see a random number get returned. This means that a deployed version of your API is working!

If you want to look at the logs of the container, or if anything went wrong, SSH into your instance from your terminal, just like we did last time. Then, you can run any docker commands (with sudo) to inspect the container.

Things that this deployment does not account for

A few things to note about this deployment that you might want to change:

  1. The images that get built are not versioned. To make rolling back easier, you might want to generate a new version tag each time the image is built. That way, you can deploy a specific image, not just whatever is the latest code in main. You might want to split up the build and deploy steps into two separate actions as well, so that you can run the deploy with a previous image version.
  2. If the container crashes when it starts up, the action still succeeds. You might want to change it so that a health check gets performed, and the action fails and automatically rolls back to the previous version if the health check fails.
  3. The previously running container gets stopped before the new one gets started. This means that there is downtime with this deployment, since we also only have a single EC2 instance running the app.

For a simple starting point, this deployment works great as is! But for a project with paying customers, you’ll probably want to address some of these shortcomings.

Next steps

In your browser, go to https://ip-address-of-your-ec2-instance/random.

It will not work, and you’ll get a This site can’t be reached error message. HTTPS will not work with this setup; only HTTP will. If you’re just testing things out for fun, that is probably fine! For a production-ready deployment, however, you will need HTTPS.

In the next and final article in this series, we’ll make HTTPS work by adding a load balancer with a custom domain.

--

--