Deploy web apps (+NGINX) to ECS with Docker
Tags: aws, docker, rails, startup
Published: 2020-10-07 21:49:05

<figcaption><span>Background Photo by <a href="https://unsplash.com/@blakeconnally?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Blake Connally</a> on <a href="https://unsplash.com/s/photos/tech?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></span></figcaption>

# **Premise**
During our first hectic startup days at [Geowox](https://geowox.com/), we’ve been used to work *"manually"* and directly on AWS resources, such as EC2, leaving out all the services that could have made our life easier.

Luckily, we’ve now fully embraced the magic world of Docker and we’re trying to manage, as much as possible, our *"infrastructure as a code"*.

---

This article assume you have some familiarity with the technologies we’re using such as AWS, Docker and Rails.
If you haven’t done it yet, [here](https://docs.docker.com/get-docker/) you can find a guide to install and set up Docker. And [here](https://guides.rubyonrails.org/v5.0/getting_started.html), a guide to getting started with Rails.
<br />
# **1. What I will cover**
In this article, I’d like to cover, on a high level:
* Containerize Ruby on Rails app and NGINX using Docker and Docker compose
* Use ECR as our Docker registry
* Use ECS to run and manage our containers

So, let’s begin.
<br />
_Notes: I’m using Ruby on Rails but, this can of course be applied to any other framework or language._
<br />
# **2. Containerize app and NGINX**
Let’s first create a simple Ruby on Rails app on your local machine by opening your terminal and typing the below command:
```
rails new ror-ecs
```
<br />
It will create a new rails project in your current directory.
Generate a new controller (_welcome_) and view (_index_):
```
rails g controller welcome index
```
<br />
Add root instruction in routes.rb:
{% gist https://gist.github.com/sfrancavilla/929060f38d8f74d9c393990ed528fafd %}

Next, we need to add [Docker](https://docs.docker.com/engine/reference/builder/) and [Docker Compose](https://docs.docker.com/compose/compose-file/) files in the project. Let’s first start with Dockerfile(s). I usually keep them and any related configuration in a separate folder inside the root folder of the app.

![Alt Docker folder structure](https://dev-to-uploads.s3.amazonaws.com/i/om5ouclyuz508f93mj9x.png)
<figcaption>Figure 1 — Docker folder structure</figcaption>
<br />
I’ve created 2 separate folders where each Dockerfile resides, one for the Ruby on Rails app and the other for the NGINX web server container (see Figure 1). Let’s have a look at them in detail.
<br />
### **2.1 Ruby on Rails (app) Dockerfile**
Below, the content of the Dockerfile for the Rails app container.

{% gist https://gist.github.com/sfrancavilla/a178a7fb5391faac260dbda9da63be10 %}

This is all we need to start our Ruby on Rails container. The basic commands are pretty straightforward and I suppose do not require further explanation.
<br />
### **2.2 NGINX Dockerfile (web)**
Below, the content of the Dockerfile for the NGINX web server container.

{% gist https://gist.github.com/sfrancavilla/5d99c977cf33f0ba4975469690dbee6d %}

Even in this case, NGINX does execute pretty simple commands including copying our _start.sh_ script and _nginx.conf_ template into the right folders inside the container.

_start.sh_ does not have much but, in my opinion, can be useful to keep clean our Dockerfile when we add more instructions such as, a simple auth or SSL (we can see that in a future part 2 of this tutorial). At the moment, the script runs 2 simple instructions inside the container:

* _envsubst ‘$RAILS_ROOT’ < /tmp/app.conf > /etc/nginx/conf.d/default.conf_ replaces any occurrence of RAILS_ROOT environment variable in our temporary conf file and move it inside the NGINX configuration folder as default
* _nginx -g “daemon off;”_ tells NGINX to stay in the foreground. As best practice, this is useful for Docker and means that there is one single process inside a single container.

{% gist https://gist.github.com/sfrancavilla/94b08c86ac092574834ca84f1d4ff39a %}

Now, let’s have a look at our nginx.conf template:

{% gist https://gist.github.com/sfrancavilla/30e960d01d082318c6f5604c99579952 %}

Basically, it defines rules on how files can be accessed by the client and redirects (reverse proxy) requests to the container where the rails app runs on port 3000.
<br />
### **2.3 Docker compose**
It’s now time to create our docker-compose file to manage our containers (Ruby on Rails and NGINX). Basically, Docker compose helps us to define and run multi-container Docker applications.
First, create a docker-compose.yml file in the main folder (see Figure 2).

![Alt Docker folder structure](https://dev-to-uploads.s3.amazonaws.com/i/d5to20h119tk8jcmzsu4.png)
<figcaption>Figure 2 — Docker compose</figcaption>
<br />
Then, copy and paste the following:

{% gist https://gist.github.com/sfrancavilla/e2995d5007b0540989b0009cb2c79580 %}
<br />
### **2.3 Test locally**
We can test on our machine that everything works as expected. In the terminal, navigate the root folder (where the _docker-compose.yml_ resides) and type:
```
docker-compose up --build
```
<br />
As from the [documentation](https://docs.docker.com/compose/reference/up/), this command simply builds the images and then start the containers in the foreground.

At the end of the process, the output is something similar to the one in Figure 3:

![Alt Output of docker-compose up
](https://dev-to-uploads.s3.amazonaws.com/i/3fy7ofkywi33ag71yjmq.png)
<figcaption>Figure 3 — Output of docker-compose up</figcaption>
<br />
The web app should now up and running. Open your browser and navigate to [http://localhost:3000](http://localhost:3000).

To interrupt the process and stop the containers (with exit code 0), just press _“ctrl + C”_.
<br />
# **3. Deploy to ECS**
We’re finally ready to deploy our web app to ECS. For the purpose of this tutorial, I prefer to use the command line interface but, you can replicate all the commands in the AWS console. In general, I believe that knowing the command line tools is pretty useful and can speed things up.

To recap, the AWS services we’re going to use:
>Amazon Elastic Container Service (**ECS**) is a highly scalable, high-performance container orchestration service that supports Docker containers and allows you to easily run and scale containerized applications on AWS.<br />
Amazon Elastic Container Registry (**ECR**) is a fully-managed Docker container registry that makes it easy for developers to store, manage, and deploy Docker container images.

<br />
### **3.1 Install aws-cli and ecs-cli**
Before we start, we need to install aws-cli and ecs-cli.
Without going into further detail, you can just follow [this](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) to install aws-cli and [this](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html) guide to configure it and set up a profile name. You can use the [default] one.

And, [this](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_CLI_installation.html) guide to install ecs-cli.

### **3.2 Push Docker images to ECR**
Before pushing our Docker images to Amazon ECR, we need to create a repository to store them.

We’re going to create 2 repositories, one for each image (Ruby on Rails/app and NGINX/web) with the following commands:

```
aws ecr create-repository --repository-name ror-ecs-app
```
<br />
and

```
aws ecr create-repository --repository-name ror-ecs-web
```
<br />
The terminal output should print something like the below:

![Alt Create registry output](https://dev-to-uploads.s3.amazonaws.com/i/dgt4jw3eefy9696vfarn.png)
<figcaption>Figure 4 — Create registry output</figcaption>
<br />
You can see some useful information related to our images here. Take note of the repository URIs as we need them later.

You can also review your newly created repositories in the AWS console under ECR service:

![Alt Repositories listed on AWS console](https://dev-to-uploads.s3.amazonaws.com/i/83kjoekqvmpd7q1v0hf1.png)
<figcaption>Figure 5 — Repositories listed on AWS console</figcaption>
<br />
Ok, we’ve created repositories for our images, now it’s time to let docker-compose know where to push them. We just need to add the repository URIs (that you saved somewhere before, right?) as below:

{% gist https://gist.github.com/sfrancavilla/0d1615e5a5091cfc84913926a31be40f %}

Let’s build again the images:

```
docker-compose build
```
<br />
You can now check if they’re present in our docker images list:

```
docker image ls
```
<br />
![Alt Our newly created images](https://dev-to-uploads.s3.amazonaws.com/i/hna4wuc92g0h1vlqww90.png)
<figcaption>Figure 6 — Our newly created images</figcaption>
<br />
As you can see, both images have been created and tagged automatically as _latest_. You can specify which image you’d like to push on the registry by its tag.

Push them to the respective repositories on ECR:

```
docker-compose push
```
<br />
If you’re not logged in to AWS yet, you should see something like the following error:

```
ERROR: denied: Your Authorization Token has expired. Please run ‘aws ecr get-login --no-include-email’ to fetch a new one.
```
<br />
As explicitly required, you need to run

```
aws ecr get-login --no-include-email
```
<br />
to retrieve the command (which includes a secret token) needed to login, which is something like the below:

```
docker login -u AWS -p <very_long_token> https://XXXXXXXX.dkr.ecr.eu-xxxx-1.amazonaws.com
```
<br />
_Note: the <very_long_token> is automatically generated and is shown in the output._

You can now login by copying the command and typing it in the terminal.
You should then see “Login succeeded” in the terminal.
Push again your images:

```
docker-compose push
```
<br />
It can take few minutes to complete.
Once completed, you can verify that the images have been uploaded by navigating to ECR Service in the AWS console.

![Alt Detail page of our app image](https://dev-to-uploads.s3.amazonaws.com/i/pa5g44n1sn6gzxvqhpwo.png)
<figcaption>Figure 7 — Detail page of our app image</figcaption>
<br />
Well done so far, we’re close to have our web app up and running in the cloud.

### **3.2 Publish your Ruby on Rails app with ECS**
We’ve already described what Amazon ECS is but, let’s further explore some basic ECS components such as _cluster_ and _task definition_.

A _cluster_ is a logical grouping of running resources (e.g. tasks). In our case, considering that we’re running tasks that use the EC2 launch type, a cluster is also a grouping of container instances. You can create multiple clusters in an account to keep your resources separate.

Before running Docker containers on Amazon ECS, we must create a task definition.
A _Task definition_ is a JSON file that describes one or more containers in your application. There are different parameters you can specify inside a task definition file, such as Docker images, CPU usage, IAM role, etc.

The _ecs-cli compose_ command that we’re going to use, allows us to create task definitions and manage our Amazon ECS tasks using our Docker compose file. Basically, what happen is that ECS will “convert” _docker-compose.yml_ into a single task definition where the 2 containers, defined as _services_, will run.

Without any further specification, the _ecs-cli_ commands look for a Docker compose file in the current directory, named _docker-compose.yml_.

The first thing that we should do is to locally define a cluster configuration before creating it on ECS. This configuration can then be passed as a parameter to the ecs-cli commands. So, let’s define a cluster configuration by running the following command on terminal:

```
ecs-cli configure --cluster ror-ecs-cluster --region eu-west-1 --config-name ror-ecs-conf --cfn-stack-name ror-ecs-stack --default-launch-type ec2
```
<br />
* _cluster_ Specifies the name of our cluster. In this case I’ve chosen ror-ecs-cluster but, feel free to use a different name. _Required_
* _region_ Specifies the region where the cluster is created. In my case is eu-west-1 (Ireland) but, feel free to change it to the one you are using. _Required_
* _config-name_ Specifies the configuration name that we’ll refer to when we’ll create the cluster. If it’s not present, the name is set to default
* _cfn-stack-name_ Specifies the stack name in CloudFormation. If omitted, the name is defined as amazon-ecs-cli-setup-<cluster_name>
* _default-launch-type_ Specifies the default launch type to use between FARGATE and EC2. In our case we will be using EC2. If not defined, no launch type is used.

If everything works as expected, the output should be:

```
INFO[0000] Saved ECS CLI cluster configuration ror-ecs-conf.
```
<br />
Your configuration is saved locally in the _~/.ecs/config_ file:

```
version: 1
default: default
clusters:
  ror-ecs-conf:
    cluster: ror-ecs-cluster
    region: eu-west-1
    cfn-stack-name: ror-ecs-stack
    default_launch_type: EC2
```
<br />
The next step is to create our cluster using the configuration we’ve previously defined:

```
ecs-cli up --keypair id_rsa --capability-iam --size 1 --instance-type t2.medium --cluster-config ror-ecs-conf
```
<br />
* _keypair_ Specifies the name of an existing Amazon EC2 key pair to enable SSH access to the EC2 instances in your cluster. It’s not required but useful if you want to have access to the EC2 instance(s) that will be created
* _capability-iam_ Specifies the creation of IAM resources. In this case it is required
* _size_ Specifies the number of instances to launch and register to the cluster. The default value is 1 so, in this case, it would not be required.
* _instance-type_ Specifies the instance type. In our case, we’re creating a t2.medium instance
* _cluster-config_ Specifies the name of our cluster. For our example, I’ve chosen ror-ecs-cluster but, feel free to use a different name

The output will be something like the following:

![Alt ecs-cli up command output](https://dev-to-uploads.s3.amazonaws.com/i/ljxau6u22z04q9qa2mcu.png)
<figcaption>Figure 8 — ecs-cli up command output</figcaption>
<br />
As you can see, CloudFormation took care of creating some resources for us such as VPC, Security Groups, Subnets, etc.

Log into the AWS console and you can have a look at the resources created by the CloudFormation stack:

![Alt ror-ecs-stack on AWS console](https://dev-to-uploads.s3.amazonaws.com/i/0z2f0b9rhocfd64qwzzo.png)
<figcaption>Figure 9 — ror-ecs-stack on AWS console</figcaption>
<br />
Also, you can see the newly created cluster:

![Alt Cluster detail page on AWS console](https://dev-to-uploads.s3.amazonaws.com/i/9536yt41oywdietnaak4.png)
<figcaption>Figure 10 — Cluster detail page on AWS console</figcaption>
<br />
If you scroll down, under the “ECS Instances” tab, you can also review the EC2 instance details that has been created and launched:

![Alt EC2 instance details on ECS cluster](https://dev-to-uploads.s3.amazonaws.com/i/by8w33zjjsfubhbjo2o9.png)
<figcaption>Figure 11 — EC2 instance details on ECS cluster
</figcaption>
<br />
Ok, everything is set up for our final step: deploy our docker-compose file to our cluster; this will also create our task definition file for our containers.

```
ecs-cli compose up --cluster-config ror-ecs-conf
```
<br />
The output is the following:

![Alt ecs-cli compose command output](https://dev-to-uploads.s3.amazonaws.com/i/qecqpcpz9z7tg53o0ws4.png)
<figcaption>Figure 12 — ecs-cli compose command output</figcaption>
<br />
In the AWS console, you will see the task definition and all the default parameters:

![Alt Task definition JSON v1 on AWS console](https://dev-to-uploads.s3.amazonaws.com/i/27jpbfu0wcop6acnb5jx.png)
<figcaption>Figure 13 — Task definition JSON v1 on AWS console</figcaption>
<br />
And now? How can we access our website?
We know that public access is managed by our NGINX conf file which, at the moment, has localhost as _server_name_. So, we need to change it and we can proceed in 2 ways:

If you want to use your own a domain (e.g. example.com), you can:
* create an A record in your DNS settings that points to the EC2 machine IP address
* use the EC2 machine IP address as server name

Let’s choose the second option and add the EC2 IP address to our NGINX config file. You can retrieve the public EC2 IP address in AWS console under the EC2 instance detail page:

![Alt EC2 instance detail page on AWS console
](https://dev-to-uploads.s3.amazonaws.com/i/iz78qoudg8hwt1x02epc.png)
<figcaption>Figure 14 — EC2 instance detail page on AWS console
</figcaption>
<br />
Update the _server_name_ inside our _nginx.conf_ file:

{% gist https://gist.github.com/sfrancavilla/0fda69a82aa67f0f40f4ef1b4a267206 %}

Let’s build again the updated image with:

```
docker-compose build
```
<br />
Push it to the ECR repository:

```
docker-compose push
```
<br />
And update our cluster:

```
ecs-cli compose up --cluster-config ror-ecs-conf --force-update
```
<br />
Aaaand that’s it! You should see your web app finally online 🎉🎉🎉!

![Alt The web app is publicly available on http://18.210.50.195](https://dev-to-uploads.s3.amazonaws.com/i/u8ljhxw9g1xmg1ibze9n.jpg)
<figcaption>Figure 15 — The web app is publicly available on http://18.210.50.195</figcaption>
<br />
# **4. Conclusion**
Congratulations forgetting this far! Of course this project is just the beginning and there are a lot of improvements and customisation to be made with ECS (e.g. use an [ALB](https://docs.aws.amazon.com/AmazonECS/latest/userguide/create-application-load-balancer.html)). At the same time, I’ve tried to cover quite a good amount of concepts for you to start working with ECS.
In this tutorial I mainly wanted to be able to show different aspects I’ve been stuck while learning ECS and I really hope that you will find this useful. Here you can find the source code.
I greatly appreciate any feedback. Let me know if you enjoyed it 🙏 and [let’s connect](https://www.linkedin.com/in/stefanofrancavilla/)!

Special thanks to [Donato Francavilla](https://www.linkedin.com/in/donato-gabriele-f-45700811/) for reviewing the article!