Skip to content

Andrew Birck's Blog

Hosting a Node App on AWS EC2

March 04, 2024

I wrote a pretty simple webapp to transcribe videos for me so that I can quickly skim a summary rather than watch the whole video. I’ve been hosting it on Heroku because they handle all the provisioning and deployment for you but it has been costing me 10-15 dollars a month and has some cold start delays and the app is almost always spun down since I don’t use it frequently.

I wanted to see if hosting it on the cheapest AWS EC2 instance is any cheaper so here’s what I did to get the app up and running. I used AWS documentation to figure all this out but there didn’t seem to be a single article walking you through this task so I thought I’d document the process for myself and others.

Modify my project to create a Docker image

Obviously this process requires our project to produce a Docker image. I hadn’t actually written my own Dockerfile before but the process is fairly simple.

Create the Dockerfile

I created a file named Dockerfile at the root of my project with the contents:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN apk update \
&& apk add --no-cache redis \
&& apk add --no-cache python3 \
&& apk add --no-cache ffmpeg \
&& yarn install \
&& yarn build
EXPOSE 3000
CMD ["/bin/sh","-c","./docker-entry.sh"]

We use an existing image with node as our base, copy everything from the project into the image we’re creating, install some dependencies and then build our project.

The app is exposed on port 3000 (I should probably make a ‘release’ version that uses port 80 or 443, but this is good enough for this tutorial) so we expose that and then tell Docker to run a script called docker-entry.sh when the container starts.

We use the script to run two different commands since both redis and our webapp need to be started on the server. Maybe there’s a way to do this using just the Dockerfile but the script was trivial to write and worked well for me.

My docker-entry.sh script is:

redis-server &
yarn start

Don’t forget to update script permissions to make sure it’s executable:

chmod 755 ./docker-entry.sh

Build and test the image

I then built the image and gave it the tag audio-summarizer:

docker build -t audio-summarizer .

And tested the image by running the image with port 3000 exposed and an environment variable with my API key defined:

docker run -t -i -p 3000:3000 -e OPENAI_API_KEY=<key> audio-summarizer

After verifying things are working we’re off to AWS to set up the infrastructure we need:

Create a private repository on Elastic Container Registry

You can create a new Elastic Container Registry repository from the ECR home screen on the AWS website:

Screenshot of ECR homepage

I created a private repository with the name audio-summarizer.

To push the image we created to the AWS repository we’ll need to tag it with the repository URI which is listed on the ECR dashboard:

docker tag audio-summarizer <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com/audio-summarizer

And finally push to the AWS repository:

aws ecr get-login-password --region <your-region> | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com
docker push <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com/audio-summarizer

Note: If you get an error about aws not being installed you’ll need to go install and configure the AWS CLI. Instructions to do so are here.

Create the EC2 instance and prepare it

Create the instance

From the EC2 dashboard I clicked “launch instance”. For each section I picked whatever would lead to the cheapest (x86 architecture) EC2 instance:

  • Name: audio-summarizer
  • Application and OS Image: Amazon Linux 64-bit (x86)
  • Instance type: t2-nano
  • Key pair: Used UI to create a new key pair. I download the .pem file and stored it as ~/aws_keys/audio-summarizer-ec2.pem.
  • Network settings: I created a new security group & allowed SSH traffic from my IP. If my app was running on port 443/80 I could select either of the “Allow HTTP(S) traffic from the internet” checkboxes but we’re not. We’ll edit the security group to allow port 3000 later.
  • Configure storage: An 8 GB gp3 drive was the smallest & cheapest available

After downloading your pem file locally, make sure you change the permissions (chmod 600 audio-summarizer-ec2.pem) so no one else can read it.

SSH into the instance and install Docker

On the AWS website navigate to the EC2 instance you just created and grab any of the public addresses listed (I’ll use the public IPv4 DNS address)

Screenshot of app running in browser

We’ll use that address and the pem file from the last step to SSH into the machine. The default account on the instance is ec2-user:

ssh -i ~/aws_keys/audio-summarizer-ec2.pem ec2-user@ec2-34-220-205-159.us-west-2.compute.amazonaws.com

Now it’s time to install Docker on the instance:

sudo yum update -y
sudo yum install docker
sudo service docker start

And make sure the ec2-user has Docker access:

sudo usermod -a -G docker ec2-user
docker info

Which unfortunately gave me a permissions error:

Client:
Version: 24.0.5
Context: default
Debug Mode: false
Plugins:
buildx: Docker Buildx (Docker Inc.)
Version: v0.0.0+unknown
Path: /usr/libexec/docker/cli-plugins/docker-buildx
Server:
ERROR: permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.24/info": dial unix /var/run/docker.sock: connect: permission denied
errors pretty printing info

So I checked the permissions:

> ls -al /var/run/docker.sock
srw-rw----. 1 root docker 0 Jan 11 12:41 /var/run/docker.sock

And verified I was added to group:

> groups ec2-user
ec2-user : ec2-user adm wheel systemd-journal docker

Since everything looks right I tried a good old reboot (sudo reboot or you can use the instance dashboard) and that seemed to get things working after reconnecting:

> docker info
Client:
Version: 24.0.5
Context: default
Debug Mode: false
Plugins:
buildx: Docker Buildx (Docker Inc.)
Version: v0.0.0+unknown
Path: /usr/libexec/docker/cli-plugins/docker-buildx
Server:
Containers: 0
Running: 0
Paused: 0
Stopped: 0
Images: 0
Server Version: 24.0.5
<etc....>

Get the Docker image running on the instance

The EC2 instance doesn’t have permissions to your container registry by default. We’ll need to create an IAM role with the correct permissions and attach it to the EC2 instance.

Create the IAM role

In the AWS dashboard go to the IAM service and start the wizard to create a new role.

The ‘Trusted entity type’ is an ‘AWS service’ and under the ‘Use case’ dropdown select ‘Elastic Container Service’ then the ‘Elastic Container Service Task’ radio button that appears:

Creating an EC2 IAM role

And on the permission policies page search for the AmazonECSTaskExecutionRolePolicy and select it:

AmazonECSTaskExecutionRolePolicy

And on the last wizard page give it a meaningful name and hit ‘Create role’.

Attach to the instance

In the EC2 dashboard go to your instance and then go to ‘Actions > Security > Modify IAM Role’. On the next page select the role you just created.

Run the Docker image on your instance

SSH into your instance again and log on to your container registy and pull your image:

aws sts get-caller-identity
aws ecr get-login-password --region <your-region> | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com
docker pull <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com/audio-summarizer

And finally run your container:

docker run -t -i -p 3000:3000 -e OPENAI_API_KEY=<key> <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.com/audio-summarizer

(Optional) Authoroize inbound traffic if on a different port (optional)

If you want to expose a non-standard port (like 3000 in this example) you’ll need to edit the security group attached to the EC2 instance. From the EC2 instance dashboard select the ‘Security’ tab:

EC2 security tab

From there click the “Edit inbound rules” button in the lower section:

Security group dashboard

And add a new rule to allow the traffic (in this case a TCP rule on port 3000 w/source 0.0.0.0/0 which means “any IP address”)

Access instance

Use the instance URI we grabbed for SSH access and check out your web app running on EC2:

Screenshot of app running in browser

Thanks for reading!


Andrew Birck
A place to for me to post stuff (mostly)
about software development.