DEV Community

Cover image for Deploy Elixir + Phoenix to AWS ECS with GitLab CI and Terraform
Aleksi Holappa
Aleksi Holappa

Posted on • Updated on

Deploy Elixir + Phoenix to AWS ECS with GitLab CI and Terraform

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.

AWS & Terraform

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
Enter fullscreen mode Exit fullscreen mode

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

terraform.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 3.0"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

main.tf

provider "aws" {
  region = "eu-north-1" # Later please use the region variable
}
Enter fullscreen mode Exit fullscreen mode

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

variable "name" {
  type        = string
  description = "Name of the application"
  default     = "myapp"
}

variable "environment_name" {
  type        = string
  description = "Current environment"
  default     = "development"
}
Enter fullscreen mode Exit fullscreen mode

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

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

}
Enter fullscreen mode Exit fullscreen mode

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.

ecr.tf

resource "aws_ecr_repository" "myapp_repo" {
  name = "${var.environment_name}-${var.name}"
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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.

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
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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!

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
Enter fullscreen mode Exit fullscreen mode

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 As sidenote: This job is only run on master-branch.

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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

...

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
}
Enter fullscreen mode Exit fullscreen mode

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

# 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
  ]
}
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

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

environment/dev.tfvars

environment_name = ""

aws_region = ""

db_name = ""

db_username = ""

db_password = ""

ecs_fargate_application_cpu = "256"

ecs_fargate_application_mem = "512"

ecs_application_count = 1
Enter fullscreen mode Exit fullscreen mode

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

output "load_balancer_dns" {
  value = aws_lb.myapp.dns_name
}
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (13)

Collapse
 
organicnz profile image
Tarlan Isaev 🍓 • Edited

Hi mate,
thanks for the great and educational article :)
Could you pls help with this issue by any chance? :)

terraform plan

Error: Reference to undeclared resource

on deploy_user.tf line 24, in resource "aws_iam_user_policy" "ci_ecr_access":
24: "Resource": "${aws_ecr_repository.myapp_repo.arn}"

A managed resource "aws_ecr_repository" "myapp_repo" has not been declared in
the root module.

Collapse
 
hlappa profile image
Aleksi Holappa

This basically means that the ECR repository you are referring to in the user policy does not exist. Check the naming of the ECR repository resource.

I fixed one typo from the article, there was unnecessary "_url" in the ECR naming, sorry about that!

Collapse
 
organicnz profile image
Tarlan Isaev 🍓

Thanks for getting back to me, mate :)

Sorry that asking such silly question. Could I fix it easily by adding that ECR repo onto AWS before runnig Terrafrom commands? Unfortunatelly, I haven't touched this ECR service, yet :)

Oh, that's tottaly fine, my typos are beond my understanding lol

Thread Thread
 
hlappa profile image
Aleksi Holappa

Well, you can add it manually through AWS console if you want to. Also, you can just rename the resource and Terraform will destroy the old one and create a new one with the new name. :)

Thread Thread
 
organicnz profile image
Tarlan Isaev 🍓

Thanks bro, sorry, I didn't correct that typo earlier, I thought it's gonna be a next iteration of the following article heh :)

Collapse
 
organicnz profile image
Tarlan Isaev 🍓

Couldn't trace out this error yet when pusshing code to my GitLab repo. Have you seen it before?

$ which elixir
/usr/local/bin/elixir
$ mix --version
Erlang/OTP 22 [erts-10.7.2.4] [source] [64-bit] [smp:1:1] [ds:1:1:10] [async-threads:1]
Mix 1.10.4 (compiled with Erlang/OTP 22)
$ ln -s /usr/local/bin/mix /usr/bin/mix
$ mix local.rebar --force

  • creating /root/.mix/rebar
  • creating /root/.mix/rebar3 $ mix local.hex --force
  • creating /root/.mix/archives/hex-0.20.5 $ mix deps.get --only test ** (Mix) Could not find a Mix.Project, please ensure you are running Mix in a directory with a mix.exs file Cleaning up file based variables 00:01 ERROR: Job failed: exit code 1
Collapse
 
hlappa profile image
Aleksi Holappa • Edited

Is this output on the CI or on your local machine? If local, what OS are you using?

Collapse
 
organicnz profile image
Tarlan Isaev 🍓 • Edited

Yeah, on CI. I've already pushed that code to my repo :)
pastebin.com/vdbDW8Um
gitlab.com/organicnz/myapp-terraform

Thread Thread
 
hlappa profile image
Aleksi Holappa • Edited

I see your problem! Please place the .gitlab-ci.yml to repository which contains your Elixir + Phoenix application. It should be placed to the root of the repository.

Second thing, never push the state files publicly to any platform. They should be ignored with .gitignore, crypted with git-crypt (or other encrypting tool) or the best solution would be to use remote state.

Thread Thread
 
organicnz profile image
Tarlan Isaev 🍓 • Edited

Would it be appropriate if I use your Elixir application for the CI github.com/hlappa/microservice_exe... or anyone? :)

Jeez, that's right I completely forgot to sanitise that area, already added .gitignore file then will try to figure out how to push it to the remote state on AWS :)

Collapse
 
mstibbard profile image
mstibbard • Edited

Thanks for the fantastic write up!

1) FYI as of 1 October 2020 the GitLab free tier was reduced to 400 minutes of CI/CD.

2) The initial CI/CD run failed for me with below message in the test stage. I haven't figured out the fix for this yet.

** (DBConnection.ConnectionError) tcp connect (localhost:5432): connection refused - :econnrefused
    (db_connection 2.3.1) lib/db_connection/connection.ex:100: DBConnection.Connection.connect/2
    (connection 1.1.0) lib/connection.ex:622: Connection.enter_connect/5
    (stdlib 3.12.1) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Last message: nil
State: Postgrex.Protocol
** (Mix) The database for Hello.Repo couldn't be created: killed
Cleaning up file based variables
00:00
ERROR: Job failed: exit code 1
Enter fullscreen mode Exit fullscreen mode

3) Further down in the tutorial (code block for rds.tf) you've got:

availability_zone = var.aws_default_zone
Enter fullscreen mode Exit fullscreen mode

but you haven't defined it in your variables.tf

Collapse
 
hlappa profile image
Aleksi Holappa

Sorry for a late reply to you comment, good points!

1) Article is now updated. I didn't know GitLab reduced the minutes from 2000 to 400, which is kinda sad :(

2) Make sure you have Postgres as a service for the test job, the DB url is correctly setup and check your config/test.ex that is has proper configuration. Seems like when establishing the connection to DB fails.

3) Added to variables.tf! :)

Collapse
 
neoecos profile image
Sebastian Ortiz V.

Hi @Aleksi! Thanks for the article.

Please update the ecs task with the DATAABASE_URL envnvar.
This works.

{"name": "DATABASE_URL", "value": "ecto://${var.db_username}:${var.db_password}@${aws_db_instance.default.address}:${aws_db_instance.default.port}/${var.db_name}"}