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-alpineWORKDIR /appCOPY . .RUN apk update \&& apk add --no-cache redis \&& apk add --no-cache python3 \&& apk add --no-cache ffmpeg \&& yarn install \&& yarn buildEXPOSE 3000CMD ["/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:
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.comdocker 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)
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 -ysudo yum install dockersudo service docker start
And make sure the ec2-user has Docker access:
sudo usermod -a -G docker ec2-userdocker info
Which unfortunately gave me a permissions error:
Client:Version: 24.0.5Context: defaultDebug Mode: falsePlugins:buildx: Docker Buildx (Docker Inc.)Version: v0.0.0+unknownPath: /usr/libexec/docker/cli-plugins/docker-buildxServer: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 deniederrors pretty printing info
So I checked the permissions:
> ls -al /var/run/docker.socksrw-rw----. 1 root docker 0 Jan 11 12:41 /var/run/docker.sock
And verified I was added to group:
> groups ec2-userec2-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 infoClient:Version: 24.0.5Context: defaultDebug Mode: falsePlugins:buildx: Docker Buildx (Docker Inc.)Version: v0.0.0+unknownPath: /usr/libexec/docker/cli-plugins/docker-buildxServer:Containers: 0Running: 0Paused: 0Stopped: 0Images: 0Server 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:
And on the permission policies page search for the AmazonECSTaskExecutionRolePolicy
and select it:
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-identityaws ecr get-login-password --region <your-region> | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.<your-region>.amazonaws.comdocker 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:
From there click the “Edit inbound rules” button in the lower section:
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:
Thanks for reading!