Deploying Django Application on AWS with Terraform. GitLab CI/CD
Tags: aws, terraform, gitlab, cicd
Published: 2022-08-03 07:07:00

--- series: Django + AWS ---



In previous parts, we've [deployed the Django web application to ECS](https://dev.to/daiquiri_team/deploying-django-application-on-aws-with-terraform-minimal-working-setup-587g) and [connected PostgreSQL](...) to it. But now, we have to deploy application changes manually. In this part, we are going to automate this process with the following steps:

- Create a GitLab group and projects for the [backend](https://gitlab.com/django-aws/django-aws-backend) and the [infrastructure](https://gitlab.com/django-aws/django-aws-infrastructure) repositories.
- Add the `test` CI/CD stage to run tests.
- Add the `build` CI/CD stage to build a docker image and push it to the ECR.
- Add the `deploy` CI/CD stage to update the application on AWS.

### Creating Projects

Let's start with creating a GitLab group and projects. Go to [gitlab.com](https://gitlab.com/) and register an account. 

Then [create](https://gitlab.com/groups/new#create-group-pane) a [GitLab group](https://docs.gitlab.com/ee/user/group/). A group is like a folder for projects. You can configure shared settings like access policy and CI variables for them.

Create projects for [Django](https://gitlab.com/django-aws/django-aws-backend) and [Terraform](https://gitlab.com/django-aws/django-aws-infrastructure) in this group. Be sure to remove the "Initialize repository with a README" option to create a clean repository.

Also, add your ssh key to [SSH Keys](https://gitlab.com/-/profile/keys) section. It allows you to access your projects via Git.

Now, let's push both of our repositories to GitLab.

```shell
# Push backend
cd ../django-aws-backend
git remote add origin git@gitlab.com:django-aws/django-aws-backend 
git push --set-upstream origin main

# Push infrastructure
cd ../django-aws-infrastructure
git remote add origin git@gitlab.com:django-aws/django-aws-infrastructure 
git push --set-upstream origin main
```

Check your GitLab projects in a browser and verify that the push is successful.

### Stage: Test

Now let's add unit tests check to the Django project. Django's unit tests use a Python standard library module [unittest](https://docs.python.org/3/library/unittest.html#module-unittest). See more info about testing a Django application [here](https://docs.djangoproject.com/en/3.2/topics/testing/overview/).

Go to the Django project, activate `venv` and start a PostgreSQL Docker container:

```shell
$ cd ../django-aws-backend
$ . ./venv/bin/activate
$ docker-compose up -d
```

Let's create `django_aws/tests.py` and add a simple test to check DB connection:

```python
from django.test import TestCase
from django.db import connection


class DbConnectionTestCase(TestCase):
    def test_db_connection(self):
        self.assertTrue(connection.is_usable())
```

Run `python manage.py test` locally:

```shell
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.010s

OK
Destroying test database for alias 'default'...
```

Now, let's add this check on GitLab's side. Take a look [here](https://docs.gitlab.com/ee/ci/quick_start/) if you have no idea what GitLab CI is. Generally, GitLab will run code specified in [`.gitlab-ci.yml`](https://docs.gitlab.com/ee/ci/yaml/) on every push in the repository.

Create `.gitlab-ci.yml` file with following content:


```yaml
image: python:3.10

stages:
  - test

variables:
  POSTGRES_PASSWORD: password
  POSTGRES_DB: django_aws
  DATABASE_URL: postgres://postgres:password@postgres:5432/django_aws

test:
  services:
    - postgres:14.2
  cache:
    key:
      files:
        - requirements.txt
      prefix: ${CI_JOB_NAME}
    paths:
      - venv
      - .cache/pip
  stage: test
  script:
    - python -m venv venv
    - . venv/bin/activate
    - pip install --upgrade pip
    - pip install -r requirements.txt
    - python manage.py test
```

Explanation:

- `image: python:3.10`. By default, GitLab runs CI/CD pipeline on shared runners hosted by GitLab using the [docker executor](https://docs.gitlab.com/runner/executors/docker.html). Here we specify docker image for the executor.
- `stages:`. A pipeline can have several stages. Now we have only the `test` stage. In the future, we will have three stages: `test`, `build`, and `deploy`. GitLab will execute stages in the specified order. 
- `services: postgres:14.2`. GitLab will run PostgreSQL in a separate container during the `test` stage. So, our Django application will be able to run DB-related tests. 
- `variables: POSTGRES_PASSWORD, POSTGRES_DB DATABASE_URL` - environment variables for both Django and PostgreSQL docker containers.  
- `cache: key:`. Here we cache the result of the `pip install` command to speed up pipeline executions. If the  key `files: requirements.txt` hasn't changed since the last run GitLab will download the cache for `venv` and `.cache/pip` directories.
- `script: ...` is commands to execute. GitLab executes commands one by one. If some command returns a non-zero exit code, GitLab will interrupt pipeline execution and mark it *Failed*. If all commands have been executed successfully, GitLab marks the current stage as *Successful* and goes to the next stage. 

Now, push the changes and take a look at [CI/CD](https://gitlab.com/django-aws/django-aws-backend/-/pipelines) tab of your GitLab Django project. 


```shell
$ git add .
$ git commit -m "add gitlab-ci; add test"
$ git push
```

![Tests is running](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/gitlab_ci_test.png) 
![Tests have been passed](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/gitlab_ci_test_passed.png)


### Stage: Build

Tests are passed, so we move on to the `build` stage. At this stage, we need to connect our GitLab account with our AWS account to grant GitLab access to the ECR repository. For this, we'll create a separate AWS user `gitlab` with limited permissions. Let's go to the AWS IAM console and create a [new user](https://us-east-1.console.aws.amazon.com/iam/home#/users$new?step=details). 

> You can find instructions on how to create an AWS user [here](TODO:_Link_To_the_first_chapter)

Add to this user `AmazonEC2ContainerRegistryPowerUser` policy to enable read and write permission to any ECR repository on this account. Proceed to the final step of user creation and save `ACCESS_KEY_ID` and `SECRET_ACCESS_KEY`. 

Now go to the GitLab group settings and add `AWS_ACCOUNT_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ACCESS_KEY_ID`, and `AWS_DEFAULT_REGION` variables. GitLab runner will use these credentials for AWS CLI calls. 

{% embed https://youtu.be/rUv3P8NRAx4 %}

Make sure to mask sensitive variables `AWS_ACCOUNT_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ACCESS_KEY_ID` to hide their values in GitLab logs. Take a look at the [AWS deployment](https://docs.gitlab.com/ee/ci/cloud_deployment/index.html#aws) documentation page for more information.

Then add to the `.gitlab-ci.yml` build block:

```yaml
stages:
  - test
  - build

variables:
  ...
  DOCKER_HOST: tcp://docker:2375
  DOCKER_TLS_CERTDIR: ""
  AWS_REGISTRY_URL: "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${CI_PROJECT_NAME}:latest"

test:
  ...

build:
  stage: build
  image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
  services:
    - docker:20.10-dind
  before_script:
    - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_REGISTRY_URL
  script:
    - docker pull $AWS_REGISTRY_URL || true
    - docker build --cache-from $AWS_REGISTRY_URL -t $AWS_REGISTRY_URL .
    - docker push $AWS_REGISTRY_URL
  only:
    - main
```


Explanation:

- `image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest`. This image allows using AWS CLI commands during CI/CD.
- `services: docker:20.10-dind`. We use [docker-in-docker](https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-docker-in-docker) to build the docker image inside the [docker executor](https://docs.gitlab.com/runner/executors/docker.html). Docker starts as a separate service, so we can access the docker daemon from the GitLab job.
- `variables: DOCKER_HOST, DOCKER_TLS_CERTDIR`. Specify the path to the docker-in-docker daemon and disable TLS connection.
- `AWS_REGISTRY_URL`. Construct the ECR URL from the project name. Ensure that the names of the ECR repo and Gitlab project are the same.
- `before_script: aws ecr get-login-password`. Log in to ECR.
- `docker pull $AWS_REGISTRY_URL || true`. Pull actual image to use it as cache
- `docker push $AWS_REGISTRY_URL`. Upload image to ECR.
- `only: main`. We'll build a docker image for `main` branch only.

Commit your changes and check a CI/CD pipeline:

```shell
$ git add .
$ git commit -m "add build stage"
$ git push
```

![Build passed](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/gitlab_ci_build_passed.png)

### Stage: Deploy 

Build stage passed, now we'll deploy container from ECR to ECS. 

But let's start with running migrations. We want to run migrations as separate ECS task to avoid any side effects on web application. Go to the `infrastructure` project and make following changes in `ecs.tf` and apply changes:

```terraform
# Production cluster
...

locals {
  container_vars = {
    region = var.region

    image     = aws_ecr_repository.backend.repository_url
    log_group = aws_cloudwatch_log_group.prod_backend.name

    rds_db_name  = var.prod_rds_db_name
    rds_username = var.prod_rds_username
    rds_password = var.prod_rds_password
    rds_hostname = aws_db_instance.prod.address
  }
}

# Backend web task definition and service
resource "aws_ecs_task_definition" "prod_backend_web" {
  ...
  container_definitions = templatefile(
    "templates/backend_container.json.tpl",
    merge(
      local.container_vars,
      {
        name       = "prod-backend-web"
        command    = ["gunicorn", "-w", "3", "-b", ":8000", "django_aws.wsgi:application"]
        log_stream = aws_cloudwatch_log_stream.prod_backend_web.name
      },
    )
  )
  ...
}
...
# Cloudwatch Logs
...
resource "aws_cloudwatch_log_stream" "prod_backend_migrations" {
  name           = "prod-backend-migrations"
  log_group_name = aws_cloudwatch_log_group.prod_backend.name
}

# Migrations

resource "aws_ecs_task_definition" "prod_backend_migration" {
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = 256
  memory                   = 512

  family = "backend-migration"
  container_definitions = templatefile(
    "templates/backend_container.json.tpl",
    merge(
      local.container_vars,
      {
        name       = "prod-backend-migration"
        command    = ["python", "manage.py", "migrate"]
        log_stream = aws_cloudwatch_log_stream.prod_backend_migrations.name
      },
    )
  )
  depends_on         = [aws_db_instance.prod]
  execution_role_arn = aws_iam_role.ecs_task_execution.arn
  task_role_arn      = aws_iam_role.prod_backend_task.arn
}
```

Here we've moved the same variables for `migration` and `web` containers in `container_vars`. Then we've created a separate task definition and log stream for the migration container. Now we can the start task with a specified task definition to apply migrations for every release. 

Now let's create a deployment script. Return the Django project and create `scripts/deploy.sh` file

```shell
cd ../django-aws-backend
mkdir scripts 
touch scripts/deploy.sh 
chmod 777 scripts/deploy.sh
```

with the following content:

```shell
#!/bin/bash

set -e

# Collect ECS_GROUP_ID and PRIVATE_SUBNET_ID for running migrations
echo "Collecting data..."
ECS_GROUP_ID=$(aws ec2 describe-security-groups --filters Name=group-name,Values=prod-ecs-backend --query "SecurityGroups[*][GroupId]" --output text)
PRIVATE_SUBNET_ID=$(aws ec2 describe-subnets  --filters "Name=tag:Name,Values=prod-private-1" --query "Subnets[*][SubnetId]"  --output text)

echo "Running migration task..."
# Construct NETWORK_CONFIGURATON to run migtaion task 
NETWORK_CONFIGURATON="{\"awsvpcConfiguration\": {\"subnets\": [\"${PRIVATE_SUBNET_ID}\"], \"securityGroups\": [\"${ECS_GROUP_ID}\"],\"assignPublicIp\": \"DISABLED\"}}"
# Start migration task
MIGRATION_TASK_ARN=$(aws ecs run-task --cluster prod --task-definition backend-migration --count 1 --launch-type FARGATE --network-configuration "${NETWORK_CONFIGURATON}" --query 'tasks[*][taskArn]' --output text)
echo "Task ${MIGRATION_TASK_ARN} running..."
# Wait migration task to complete
aws ecs wait tasks-stopped --cluster prod --tasks "${MIGRATION_TASK_ARN}"

echo "Updating web..."
# Updating web service
aws ecs update-service --cluster prod --service prod-backend-web --force-new-deployment  --query "service.serviceName"  --output json

echo "Done!"
```

You can check the docs for [run-task](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ecs/run-task.html), [wait tasks-stopped](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ecs/wait/tasks-stopped.html), and [update-service](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ecs/update-service.html) commands.

Run this script locally to check it:

```shell
$ ./scripts/deploy.sh
Collecting data...
Running migration task...
Task arn:aws:ecs:us-east-2:947134793474:task/prod/dcc06d7ec1ac4bb69bba445565eddf8b running...
Updating web...
"prod-backend-web"
Done!
```

We've successfully run this script by the `admin` user. GitLab CI/CD will use the `gitlab` user, so we need to grant all required permissions.

Let's create a new policy `gitlab-deploy-ecs`, and add it to the `gitlab` user. Go to the [IAM Console](https://us-east-1.console.aws.amazon.com/iam/home), select the "Users" tab and click on the `gitlab` user. 

![](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/iam_add_ecs_policy_1.png)

Next, click on "Add inline policy" and add the JSON policy definition. You need to use your `AWS_ACCOUNT_ID` instead of `947134793474` number.

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSubnets",
                "ec2:DescribeSecurityGroups",
                "ecs:UpdateService",
                "ecs:DescribeTasks"
            ],
            "Resource": ["*"]
        },
        {
            "Effect": "Allow",
            "Action": "iam:PassRole",
            "Resource": [
                "arn:aws:iam::947134793474:role/ecs-task-execution",
                "arn:aws:iam::947134793474:role/prod-backend-task"
            ]
        },
        {
            "Effect": "Allow",
            "Action": "ecs:RunTask",
            "Resource": "arn:aws:ecs:us-east-2:947134793474:task-definition/backend-migration*"
        }
    ]
}
```

![](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/iam_add_ecs_policy_2.png)

Explaining policies:

- `ec2:DescribeSubnets`, `ec2:DescribeSecurityGroups` — for "Collecting data..." stage.
- `ecs:RunTask`, `iam:PassRole` — for running a migration ECS task.
- `ecs:DescribeTasks` — for waiting a migration ECS task ends.
- `ecs:UpdateService` — for updating the ECS Django web application.

Click "Review Policy", name the policy `gitlab-ecs-deploy` and click "Create Policy". Now, the `gitlab` user will be able to execute the `deploy.sh` script.


Add deploy stage to `.gitlab-ci.yml`

```yaml
...

stages:
  - test
  - build
  - deploy

...

deploy:
  stage: deploy
  image: registry.gitlab.com/gitlab-org/cloud-deploy/aws-base:latest
  script:
    - ./scripts/deploy.sh
  only:
    - main
```

At the deploy stage, we simply run `deploy.sh` script. We use the `aws-base` image to have access to AWS CLI commands.


Finally, let's add some changes to Django to see that our application will be updated automatically. Let's change the Django Admin header text. Add this line to `django_aws/urls.py`:

```python
admin.site.site_header = "Django AWS Admin Panel"
```

Now commit your changes and see how's your pipeline going.

```shell
$ git add .
$ git commit -m "add deploy stage"
$ git push
```

![](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/gitlab_ci_deploy_passed.png)


Check your admin page `prod-57218461274.us-east-2.elb.amazonaws.com/admin` and see a new title. It can take some time to update the ECS service. 

![](https://s3.eu-central-1.wasabisys.com/daiquiri-external-content/blog/eugen1j/django-aws/img/successful_deployment.png)

Also, do not forget to push infrastructure code to GitLab. 

Congratulations! We've successfully set up CI/CD with GitLab for our web Django web application. Now we can commit our code to the `main` branch,  and GitLab CI/CD will automatically test, build and deploy it on the AWS.

But the `prod-57218461274.us-east-2.elb.amazonaws.com` domain looks not user-friendly :) Also, we need to secure the connection between a user and the Django application with an SSL certificate. In the next part, we'll connect the Namecheap domain to AWS and set up an SSL certificate for them.

You can find the source code of backend and infrastructure projects [here](https://gitlab.com/django-aws/django-aws-backend) and [here](https://gitlab.com/django-aws/django-aws-infrastructure).

If you need technical consulting on your project, check out our [website](https://daiquiri.team/services/technical-consulting?utm_medium=referral&utm_source=dev_to&utm_campaign=django_aws_3) or connect with me directly on [LinkedIn](https://www.linkedin.com/in/yevhen-bondar/).