Deploy Elixir + Phoenix to AWS ECS with GitLab CI and Terraform
Tags: aws, elixir, docker, webdev
Published: 2020-10-04 15:57:07

On my first article we created a nice local development environment for Elixir and Phoenix with Docker and docker-compose. Now it's time to write some CI scripts and Terraform templates for our continuous integration and AWS infrastructure.

We are going to do things in the following order:

1. Make sure you have all ready and set
2. Create needed resources to AWS to push deploy-ready images to AWS (ECR, IAM users, policies)
3. Write `.gitlab-ci.yml` except deploy stage
4. Create rest of the resources (ECS, LB, RDS etc.) in order to run our application
5. Add deploy-stage to `.gitlab-ci.yml`

Note: **_This is not production grade infrastructure setup, but rather one which can be used as a base._**

## Prerequisites

**GitLab**
- Phoenix application in GitLab repository
- Pipelines & Container Registry enabled in GitLab project settings
- Production ready Dockerfile for the Phoenix application/project

In case you don't have optimized Dockerfile for cloud deployments, read [this](https://hexdocs.pm/phoenix/releases.html#containers).

**AWS & Terraform**
- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) configured with your credentials (~/.aws/credentials)
- [Terraform CLI](https://learn.hashicorp.com/tutorials/terraform/install-cli) tool installed

Note: GitLab offers 400 minutes of CI time and container registry for free users (private and public repositories). (Updated 16.12. free CI minutes reduced from 2000 to 400)

## Creating the initial resources

ECR stands for Elastic Container Registry, which will hold our Docker images. These Docker images are built and tagged in our CI script and pushed to this registry. In order to push to this registry we need to setup the registry itself, an IAM user with sufficient permissions and the CI script.

Once you have your AWS CLI and Terraform CLI setup, you will create a new directory for the terraform files.

```
$ mkdir myapp-terraform
$ cd myapp-terraform
```

wonderful, now we can start creating and defining our terraform templates.

#### **`terraform.tf`**
```terraform
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}
```

Here we are saying that we want to use AWS as our provider and the version of the AWS provider, pretty simple.

#### **`main.tf`**
```terraform
provider "aws" {
  region = "eu-north-1" # Later please use the region variable
}
```

In `main.tf` we actually define the provider which is required by the definition in `terraform.tf`. Geographically my closest region is `eu-north-1` so I'm using it. I advice to choose region based on your resource needs and geographical location. Note that all AWS services/resources my not be available in your region (e.g. CDN).If that's the case, you can define multiple providers with different regions.

#### **`variables.tf`**
```terraform
variable "name" {
  type        = string
  description = "Name of the application"
  default     = "myapp"
}

variable "environment_name" {
  type        = string
  description = "Current environment"
  default     = "development"
}
```

Here we can define variables for our Terraform templates. These are very handy when you want to have different configurations in dev, staging and production environments. As seen, we define the name of the variable, what type the variable is, description for it and a default value. These variables can be populated or the default values can be replaced with `*.tfvars` files (e.g. `dev.tfvars`, `stag.tfvars` and so on). We will dive in to these later on and how to use environment specific variables.

#### **`deploy_user.tf`**
```terraform
resource "aws_iam_user" "ci_user" {
  name = "ci-deploy-user"
}

resource "aws_iam_user_policy" "ci_ecr_access" {
  user = aws_iam_user.ci_user.name

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "ecr:GetAuthorizationToken"
      ],
      "Effect": "Allow",
      "Resource": "*"
    },
    {
      "Action": [
        "ecr:*"
      ],
      "Effect": "Allow",
      "Resource": "${aws_ecr_repository.myapp_repo.arn}"
    }
  ]
}
EOF

}
```

In order to push to the upcoming ECR, we need user with sufficient permissions to do that. Here we are defining a IAM user resource named `ci_user` and below that we are assigning policies to that user. The policy is in `JSON`-format and holds the versin and actual statements in an array. On the first statement we are giving specific permissions for our `ci_user` to get the authorization. On the second statement we are giving all the access rights to our ECR resource (write, read etc.). Read more on [here](https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html).

#### **`ecr.tf`**
```terraform
resource "aws_ecr_repository" "myapp_repo" {
  name = "${var.environment_name}-${var.name}"
}
```

This is our final resource till now. This may seem relatively simple, and it is, we are defining an ECR repository for our Docker images. The big part here is how we define the name for the repository. The repository name consists our environment and the application name, so the end result now would be `development-myapp` since those are our default values in `variables.tf`. Let's say that we want repositories for staging and production also. We can create corresponding `.tfvars`-files for them and override the default value which was set in `variables.tf`. Later on, we will be creating environment specific variable files, but for now I'm leaving them out for simplicity.

Now we have all resources defined and ready to be applied to AWS, but first we will take a look to Terraform workspaces.

## Terraform workspaces

Workspaces in Terraform are kind of different environments where you want to deploy your infrastructure. They help you to keep track of the state of different workspaces/environments.

As default we only have one workspace named `default`. Lets create a now one!

```
$ terraform workspace new dev

Created and switched to workspace "dev"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
```

Wonderful, now we have a new workspace named `dev`. Now we can try to *plan* our infrastructure. **Note**: For the next steps your credentials in the `~/.aws/credentials` must be in place. Terraform will respect the `AWS_PROFILE` environment variable if set, otherwise it will use the default credentials from the earlier mentioned file.

```
$ terraform plan
```

What this does, it checks if already existing state file is present, compares your terraform template changes against this state file and prints nicely all the resources which are going to be changed, destroyed or created. When you run above command, you should see that we are creating 3 different resources, ECR repository, IAM user and IAM user policy.

That was the initial planning and checking that our properties for the resources are correct, and we didn't do any mistakes. Now it's time to actually apply this to our AWS environment.

```
$ terraform apply
```

Terraform will ask for permissions to perform the listed actions in workspace `dev`. As Terraform instructs, write yes and your resources will be created in a few seconds! During this, Terraform will create a new folder called `terraform.tfstate.d`. This folder holds subfolders of your applied workspaces. If you go to `terraform.tfstate.d/dev/` you will find a `.tfstate`-file from there. This file holds the current state of the infrastructure for that particular workspace. This is kinda against the good practises since usually state files are stored somewhere else than locally or in git repository. [Read more](https://www.terraform.io/docs/state/remote.html)

In case you ran to an error, check your credentials and syntax of the templates.

Nice, now we have our ECR and IAM user for GitLab CI ready. We can start writing the CI script and start pushing some container images to our newly created repo!


## GitLab CI

The GitLab CI script will be just basic `yaml` file with definitions for jobs, which image to use as a base image since it is Docker/container based CI runtime and many other things!

Things to do first. We need to think what to include to our CI pipeline. Below is a list of `stages` which we are including to our CI script. You can run multiple jobs parallel in each stage, e.g. we can run security scan and tests at the same time.

1. Test
2. Build
3. Release
4. deploy

At this point of the article, I'm going to cover the first three stages and we will revisit the deploy stage later when we have infrastructure ready for it (ECS, RDS etc). **Note:** `.gitlab-ci.yml` goes to the same repository where your Elixir + Phoenix application is, and also the CI environment variables go there. 

### Configuring CI environment

In order to push images to our ECR repository we need to have access to that repository.

Go to your AWS console, navigate to IAM dashboard, go to users and you should see our previously created CI user there. Click the CI user, select `security credentials` and create access key. This will create access key ID and secret access key for our CI user. Save these two values from the modal view. Other value you need is the ECR repository URL. You can find it by searching for `Elastic Container Registry` --> repositories and you should see earlier created repository and its URI, copy it.

Go to your GitLab repository and navigate settings --> CI/CD --> expand variables. We are going to add following variables:

- `AWS_REGION`
- `AWS_ACCESS_KEY_ID`
- `AWS_SECRET_ACCESS_KEY`
- `DEV_AWS_ECR_URI`

Note: Nowadays you can mask CI environment variables, I suggest you to do so for the access key ID, secret access key and ECR URI.

### .gitlab-ci.yml

Once we have all the variables in place, we can start writing our script.

```yaml
stages:
  - test
  - build
  - release

variables:
  AWS_DEFAULT_REGION: $AWS_REGION
  CONTAINER_IMAGE: $CI_REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHA
  AWS_CONTAINER_IMAGE_DEV: $DEV_AWS_ECR_URI:latest
```

First we define our stages and environment variables on top of the file. The variables are defined on top-level, so they are present in all jobs.

```yaml
test:
  image: elixir:1.10.4-alpine
  services:
    - postgres:11-alpine
  variables:
    DATABASE_URL: postgres://postgres:postgres@postgres/myapp_?
    APP_BASE_URL: http://localhost:4000
    POSTGRES_HOST_AUTH_METHOD: trust
    MIX_ENV: test
  before_script:
    - mix local.rebar --force
    - mix local.hex --force
    - mix deps.get --only test
    - mix ecto.setup
  script:
    - mix test
```

Here is our test job which is responsible for running our unit tests. We are using Elixir Alpine based image as a runtime image for this job and defined Postgres as our service, since our tests require database connection. Variables are pretty self explanatory, DB accepts all the connections which are coming to it, MIX_ENV is set to test and all the environment variables for the application itself are present. Before running the actual tests, we install rebar and hex, fetch the dependencies and setup the database. After that we run tests!


```yaml
build:
  stage: build
  image: docker:19.03.13
  services:
    - docker:19.03.13-dind
  before_script:
  - echo -n $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
  script:
    - docker build -f Dockerfile.production -t $CONTAINER_IMAGE .
    - docker push $CONTAINER_IMAGE
  only:
    - master
```

On this build job we actually build the Docker image which contains our application. We use official Docker image as our job runtime image and defined docker dind image as our service, since we are going to need some tools from it and we can run "docker in docker". The `before_script` one-liner will log in to GitLab container registry. On the `script` we are building our Docker image and pushing it to GitLab container registry (username/project-name). We are accessing our variables/environment variables with $-prefix notation. These can be variables we defined at the top of the CI script or environment variables which is placed in GitLab project settings. Find more on [here](https://docs.gitlab.com/ee/ci/variables/predefined_variables.html) **As sidenote:** This job is only run on `master`-branch.


```yaml
release_aws_dev:
  stage: release
  variables:
    AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
  dependencies:
    - build
  image: docker:19.03.13
  services:
    - docker:19.03.13-dind
  before_script:
    - apk add --no-cache curl jq python3 py3-pip
    - pip install awscli
    - echo -n $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
    - $(aws ecr get-login --no-include-email)
  script:
    - docker pull $CONTAINER_IMAGE
    - docker tag $CONTAINER_IMAGE $AWS_CONTAINER_IMAGE_DEV
    - docker push $AWS_CONTAINER_IMAGE_DEV
  only:
    - master
```

In release job we are releasing/pushing the recently built image to our ECR repository. For that, we need to define AWS credentials for the job as environment variables for the job runtime. We are using same runtime image and service for this job as in build job. In `before_script` we are installing needed tools to run AWSCLI, logging in to the GitLab container registry and AWS ECR repository. Once we have logged in, in `script` we pull the image which we built in the `build` job, tag it with AWS ECR repository URL which contains the repository name and `:latest`-tag. After that we push the image to the ECR.

#### **`.gitlab-ci.yml`**
```yaml
stages:
  - test
  - build
  - release

variables:
  AWS_DEFAULT_REGION: $AWS_REGION
  CONTAINER_IMAGE: $CI_REGISTRY/$CI_PROJECT_PATH:$CI_COMMIT_SHA
  AWS_CONTAINER_IMAGE_DEV: $DEV_AWS_ECR_URI:latest

test:
  stage: test
  image: elixir:1.10.4-alpine
  services:
    - postgres:11-alpine
  variables:
    DATABASE_URL: postgres://postgres:postgres@postgres/yourapp_?
    APP_BASE_URL: http://localhost:4000
    POSTGRES_HOST_AUTH_METHOD: trust
    MIX_ENV: test
  before_script:
    - mix local.rebar --force
    - mix local.hex --force
    - mix deps.get --only test
    - mix ecto.setup
  script:
    - mix test

build:
  stage: build
  image: docker:19.03.13
  services:
    - docker:19.03.13-dind
  before_script:
  - echo -n $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
  script:
    - docker build -f Dockerfile.production -t $CONTAINER_IMAGE .
    - docker push $CONTAINER_IMAGE
  only:
    - master

release_aws_dev:
  stage: release
  variables:
    AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
  dependencies:
    - build
  image: docker:19.03.13
  services:
    - docker:19.03.13-dind
  before_script:
    - apk add --no-cache curl jq python3 py3-pip
    - pip install awscli
    - echo -n $CI_JOB_TOKEN | docker login -u gitlab-ci-token --password-stdin $CI_REGISTRY
    - $(aws ecr get-login --no-include-email)
  script:
    - docker pull $CONTAINER_IMAGE
    - docker tag $CONTAINER_IMAGE $AWS_CONTAINER_IMAGE_DEV
    - docker push $AWS_CONTAINER_IMAGE_DEV
  only:
    - master
```


If you did everything correctly, your CI pipeline should pass and your image should show up in the ECR repository! Next up we will actually deploy the application to the cloud!


## AWS ECS, RDS, LB and all other folks

Now when we have our images being built and pushed to ECR, it's time to look for the actual deployment. We will defining Terraform resources for database (RDS), container service which is responsible for running the application image (ECS Fargate), load-balancing ((A)LB), networking (subnets, route tables etc.), security groups, some `.tfvars`-files and output some values once we have applied templates to AWS.

Lets get started!


#### **`main.tf`**
```terraform
...

resource "aws_vpc" "default" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Environment = var.environment_name
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.default.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.default.id
}

resource "aws_route_table_association" "public_subnet" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "private_subnet" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}

resource "aws_eip" "nat_ip" {
  vpc = true
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.default.id
}

resource "aws_nat_gateway" "ngw" {
  subnet_id     = aws_subnet.public.id
  allocation_id = aws_eip.nat_ip.id
}

resource "aws_route" "public_igw" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

resource "aws_route" "private_ngw" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.ngw.id
}
```

Add VPC (Virtual Private Cloud) resource below the provider. This will be our cloud environment where all other resources will be in. Below it we are configuring a lot of network resources. We are defining route tables for two different subnets (which we will define next), one is exposed to public internet with internet gateway and the other one is private subnet behind NAT gateway. This way our upcoming ECS service can talk to internet (for pulling images from ECR), but no one can get in to it.

#### **`subnet.tf`**
```terraform
resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.default.id
  cidr_block = "10.0.1.0/24"

  tags = {
    Environment = var.environment_name
  }
}

resource "aws_subnet" "private" {
  vpc_id     = aws_vpc.default.id
  cidr_block = "10.0.2.0/24"

  tags = {
    Environment = var.environment_name
  }
}

resource "aws_subnet" "db_a" {
  vpc_id            = aws_vpc.default.id
  cidr_block        = "10.0.3.0/24"
  availability_zone = "${var.aws_region}a"

  tags = {
    Environment = var.environment_name
  }
}

resource "aws_subnet" "db_b" {
  vpc_id            = aws_vpc.default.id
  cidr_block        = "10.0.4.0/24"
  availability_zone = "${var.aws_region}b"

  tags = {
    Environment = var.environment_name
  }
}


resource "aws_db_subnet_group" "default" {
  name        = "${var.environment_name}-${var.name}-db"
  description = "Subnet group for DB"
  subnet_ids  = [aws_subnet.db_a.id, aws_subnet.db_b.id]

  tags = {
    Environment = var.environment_name
  }
}
```

Here we have those two subnets, public and private. Also we have defined two subnets for our RDS database instance, which are in different availability zones (AZ). The current upcoming RDS setup requires us to have two availability zones in subnets where it is placed in to. Finally we can create subnet group for the RDS and configure these two DB subnets to it.

#### **`security_groups.tf`**
```terraform
resource "aws_security_group" "http" {
  name        = "http"
  description = "HTTP traffic"
  vpc_id      = aws_vpc.default.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "TCP"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "https" {
  name        = "https"
  description = "HTTPS traffic"
  vpc_id      = aws_vpc.default.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "TCP"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "egress-all" {
  name        = "egress_all"
  description = "Allow all outbound traffic"
  vpc_id      = aws_vpc.default.id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "myapp-service" {
  name   = "${var.environment_name}-${var.name}-service"
  vpc_id = aws_vpc.default.id

  ingress {
    from_port   = 4000
    to_port     = 4000
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}


resource "aws_security_group" "db" {
  name        = "${var.environment_name}-${var.name}-db"
  description = "Security group for database"
  vpc_id      = aws_vpc.default.id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.myapp-service.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}
```

Security groups help you to restrict traffic from your resources. E.g. we are only allowing incoming traffic (ingress) to specific ports. Also we can restric the outgoing traffic (egress) to specific IPs etc. These security groups are applied to other resources in our infrastructure (RDS, ECS, Load balancer etc.).

#### **`rds.tf`**
```terraform
resource "aws_db_instance" "default" {
  allocated_storage = var.db_storage
  engine            = var.db_engine
  engine_version    = var.db_engine_version
  instance_class    = var.db_instance_type
  name              = var.db_name
  username          = var.db_username
  password          = var.db_password

  availability_zone = var.aws_default_zone

  publicly_accessible    = false
  vpc_security_group_ids = [aws_security_group.db.id]
  db_subnet_group_name   = aws_db_subnet_group.default.name

  tags = {
    App         = var.name
    Environment = var.environment_name
  }
}
```

All the properties are configurable through variables for RDS resource. We are also assigning security group for this resource, and also the subnet group which we defined earlier.


#### **`lb.tf`**
```terraform
resource "aws_lb" "myapp" {
  name = "${var.environment_name}-${var.name}"

  subnets = [
    aws_subnet.public.id,
    aws_subnet.private.id
  ]

  security_groups = [
    aws_security_group.http.id,
    aws_security_group.https.id,
    aws_security_group.egress-all.id
  ]

  tags = {
    Environment = var.environment_name
  }
}

resource "aws_lb_target_group" "myapp" {
  port        = "4000"
  protocol    = "HTTP"
  vpc_id      = aws_vpc.default.id
  target_type = "ip"

  health_check {
    enabled = true
    path = "/health"
    matcher = "200"
    interval = 30
    unhealthy_threshold = 10
    timeout = 25
  }

  tags = {
    Environment = var.environment_name
  }

  depends_on = [aws_lb.myapp]
}

resource "aws_lb_listener" "myapp-http" {
  load_balancer_arn = aws_lb.myapp.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    target_group_arn = aws_lb_target_group.myapp.arn
    type             = "forward"
  }
}
```

This is setup for our application load balancer. It will accept basic HTTP requests to port 80 and forward them to our container. If you want to have HTTPS, you must assign certificate to the `aws_lb_target_group`. You can get one from AWS ACM, but I'm not covering it in this article. For real life production grade systems you want to have SSL/HTTPS always enabled.


#### **`ecs.tf`**
```terraform
# Role for ECS task
# This is because our Fargate ECS must be able to pull images from ECS
# and put logs from application container to log driver

data "aws_iam_policy_document" "ecs_task_exec_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecsTaskExecutionRole" {
  name               = "${var.environment_name}-${var.name}-taskrole-ecs"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_exec_role.json
}

resource "aws_iam_role_policy_attachment" "ecs_task_exec_role" {
  role       = aws_iam_role.ecsTaskExecutionRole.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# Cloudwatch logs

resource "aws_cloudwatch_log_group" "myapp" {
  name = "/fargate/${var.environment_name}-${var.name}"
}

# Cluster

resource "aws_ecs_cluster" "default" {
  depends_on = [aws_cloudwatch_log_group.myapp]
  name       = "${var.environment_name}-${var.name}"
}

# Task definition for the application

resource "aws_ecs_task_definition" "myapp" {
  family                   = "${var.environment_name}-${var.name}-td"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.ecs_fargate_application_cpu
  memory                   = var.ecs_fargate_application_mem
  network_mode             = "awsvpc"
  execution_role_arn       = aws_iam_role.ecsTaskExecutionRole.arn
  container_definitions    = <<DEFINITION
[
  {
    "environment": [
      {"name": "SECRET_KEY_BASE", "value": "generate one with mix phx.gen.secret"}
    ],
    "image": "${aws_ecr_repository.myapp_repo.repository_url}:latest",
    "name": "${var.environment_name}-${var.name}",
    "portMappings": [
        {
            "containerPort": 4000
        }
      ],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": {
        "awslogs-group": "${aws_cloudwatch_log_group.myapp.name}",
        "awslogs-region": "${var.aws_region}",
        "awslogs-stream-prefix": "ecs-fargate"
      }
    }
  }
]
DEFINITION
}


resource "aws_ecs_service" "myapp" {
  name            = "${var.environment_name}-${var.name}-service"
  cluster         = aws_ecs_cluster.default.id
  launch_type     = "FARGATE"
  task_definition = aws_ecs_task_definition.myapp.arn
  desired_count   = var.ecs_application_count

  load_balancer {
    target_group_arn = aws_lb_target_group.myapp.arn
    container_name   = "${var.environment_name}-${var.name}"
    container_port   = 4000
  }

  network_configuration {
    assign_public_ip = false

    security_groups = [
      aws_security_group.egress-all.id,
      aws_security_group.myapp-service.id
    ]
    subnets = [aws_subnet.private.id]
  }

  depends_on = [
    aws_lb_listener.myapp-http,
    aws_ecs_task_definition.myapp
  ]
}
```

There is a lot happening here, but don't get overwhelmed. We are first creating execution role for the ECS task definition (see the comment in the template). After that we define the lob group and the actual ECS cluster. The `aws_ecs_task_definition` is where all the important configuration happens to you container and environment in and around it. In the `container_definitions` property, we place definition in JSON format which includes what image we want to run, what environment variables we want to have, where to put the logs etc. Last but not least, we define service where we are going to run that task definition. This basically glues our task definition, cluster, load balancer etc. together.


#### **`variables.tf`**
```terraform
variable "name" {
  type        = string
  description = "Name of the application"
  default     = "myapp"
}

variable "environment_name" {
  type        = string
  description = "Current environment"
  default     = "development"
}

variable "aws_region" {
  type        = string
  description = "Region of the resources"
}

variable "aws_default_zone" {
  type        = string
  description = "The AWS region where the resources will be created"
}

variable "db_storage" {
  type        = string
  description = "Storage size for DB"
  default     = "20"
}

variable "db_engine" {
  type        = string
  description = "DB Engine"
  default     = "postgres"
}

variable "db_engine_version" {
  type        = string
  description = "Version of the database engine"
  default     = "11"
}

variable "db_instance_type" {
  type        = string
  description = "Type of the DB instance"
  default     = "db.t3.micro"
}

variable "db_name" {
  type        = string
  description = "Name of the db"
}

variable "db_username" {
  type        = string
  description = "Name of the DB user"
}

variable "db_password" {
  type        = string
  description = "Name of the DB user"
}

variable "ecs_fargate_application_cpu" {
  type        = string
  description = "CPU units"
}

variable "ecs_fargate_application_mem" {
  type        = string
  description = "Memory value"
}

variable "ecs_application_count" {
  type        = number
  description = "Container count of the application"
  default     = 1
}
```

Here are the variables which we are uring across the Terraform templates.

#### **`environment/dev.tfvars`**
```terraform
environment_name = ""

aws_region = ""

db_name = ""

db_username = ""

db_password = ""

ecs_fargate_application_cpu = "256"

ecs_fargate_application_mem = "512"

ecs_application_count = 1
```

Here you can fill the values for the variables. Note that you can create multiple workspaces e.g. dev, stag and prod, and also create `.tfvars`-files accordingly dev/stag/prod.tfvars. I'll show you soon how to use them.

#### **`outputs.tf`**
```terraform
output "load_balancer_dns" {
  value = aws_lb.myapp.dns_name
}
```

Here we are just printing the URI out of our load balancer. We can use this to access our application from browser (or if you application is an API, then in Postman or other equivalent software).

#### **`deploy_user.tf`**
```terraform
resource "aws_iam_user_policy" "ecs-fargate-deploy" {
  user = aws_iam_user.ci_user.name

  policy = <<POLICY
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "ecs:UpdateService",
        "ecs:UpdateTaskDefinition",
        "ecs:DescribeServices",
        "ecs:DescribeTaskDefinition",
        "ecs:DescribeTasks",
        "ecs:RegisterTaskDefinition",
        "ecs:ListTasks"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}
POLICY
}
```

Last but not least, we assign user policy to our IAM CI user. This way we can deploy a new version of the application to ECS.

Now we can start applying this to AWS.

```
$ terraform plan --var-file=environment/dev.tfvars
```

First you want to plan the current changes your infrastructure. This will catch any typos and configuration mistakes in your templates. If it goes through without any problems, you can start applying.

```
$ terraform apply --var-file=environment/dev.tfvars
```

Terraform will ask you do you want to apply these changes, go through the changes and if they look good, hit Terraform with a `yes` answer. Now it will take some time to create all the resources to AWS. Grab a cup of coffee/tea/whatever and watch.

Now we are pretty much done for the infrstructure part. Next we will update our CI script and add the `deploy` job to it which will update the ECS service with newly built application Docker image from ECR.


## Update .gitlab-ci.yml

Below you can see the changes we must do in order to add deploy job to our CI pipeline.

#### **`.gitlab-ci.yml`**
```yaml
stages:
  - test
  - build
  - release
  - deploy

...

...

...

deploy_aws_dev:
  stage: deploy
  dependencies:
    - release_aws_dev
  image: alpine:latest
  variables:
    AWS_ACCESS_KEY_ID: $AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY: $AWS_SECRET_ACCESS_KEY
  before_script:
    - apk add --no-cache curl jq python3 py3-pip
    - pip install awscli
  script:
    - aws ecs update-service --force-new-deployment --cluster development-myapp --service development-myapp-service
  only:
    - master
```

First add deployment stage on top of the file and then we add the job on the bottom of the file. In `before_script` we install needed tools and AWSCLI. On script we make the actual deployment. This tells to update the service from the defined cluster.

# Conclusions

If you are developing a small scale application, this kind of infrstructure may be an "overkill" for it, but for medium to large sized applications this would be a good starting point. Elixir + Phoenix itself can handle concurrently large amount of requests, but ability to be able to scale underlaying infrastructure with Terraform is a feature you want to have when your application grows.

Things to do better:

- HTTPS/SSL certificate and domain for the application
        - This is pretty straight forward, but I decided to leave it out for now. It would require ACM resource with output of the domain validation options for setting CNAME record to you domain DNS.
- Better Terraform variable usage
        - We could map multiple subnet AZ to single variable and use Terraform's functions to map those values.
- VPC endpoints
        - Instead of accessing ECR images through NAT from ECS, we could define VPC Endpoints for ECR, S3 and CloudWatch. This way we could keep all the traffic on the private network.

This was a fun little project and gives you a nice starting point for you infrastructure and CI pipelines in GitLab for Elixir + Phoenix. This article came out less "elixiry" than I wanted, this same template can be applied for other languages as well.

Any feedback is appreciated!