Deploying Django Application on AWS with Terraform. Namecheap Domain + SSL
Tags: django, terraform, aws, namecheap
Published: 2022-08-09 11:01:00


--- series: Django + AWS ---

In previous steps, we've [deployed Django with AWS ECS](...), [connected it to the PostgreSQL RDS](...), and [set up GitLab CI/CD](...)

In this step, we are going to:

- Connect [Namecheap](https://www.namecheap.com/) domain to [Route53 DNS](https://aws.amazon.com/route53/) zone.
- Create an SSL certificate with [Certificate Manager](https://aws.amazon.com/certificate-manager/).
- Reroute HTTP traffic to HTTPS and disable ALB host for Django application.
- Add `/health/` route for Health Checks.


### Setting up Namecheap API

I already have a domain name on [Namecheap](https://www.namecheap.com/). So I choose to connect the Namecheap domain to an AWS Route53 zone. But you can [register domain](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-register.html) with AWS Route53.

First, let's enable API access for Namecheap. Look through [this](https://www.namecheap.com/support/api/intro/) guide to receive `APIKey` and add your IP to the whitelist.

Second, add the [Namecheap provider](https://registry.terraform.io/providers/namecheap/namecheap/latest/docs) to Terraform project. Add to the `provider.tf` file following code:

```terraform
terraform {
  required_providers {
    namecheap = {
      source  = "namecheap/namecheap"
      version = ">= 2.0.0"
    }
  }
}

provider "namecheap" {
  user_name   = var.namecheap_api_username
  api_user    = var.namecheap_api_username
  api_key     = var.namecheap_api_key
  use_sandbox = false
}
```

In `variables.tf` add:

```terraform
# Namecheap
variable "namecheap_api_username" {
  description = "Namecheap APIUsername"
}
variable "namecheap_api_key" {
  description = "Namecheap APIKey"
}
```

Also add `TF_VAR_namecheap_api_username` and `TF_VAR_namecheap_api_key` variables to `.env` to provide values to the corresponding Terraform variables.  

```dotenv
TF_VAR_namecheap_api_username=YOUR_API_USERNAME
TF_VAR_namecheap_api_key=YOUR_API_KEY
```

Import `.env` variables with `export $(cat .env | xargs)` and run `terraform init` to add a Namecheap provider to the project. 

### Connecting Domain to AWS

Now, let's create a Route53 zone for the Namecheap domain and set up AWS nameservers. Thus, all DNS queries will be routed to the AWS Route53 nameservers, and we can manage DNS records from the AWS Route53 zone. 

Add to the `variables.tf` following code:

```terraform
# Domains
variable "prod_base_domain" {
  description = "Base domain for production"
  default = "example53.xyz"
}
variable "prod_backend_domain" {
  description = "Backend web domain for production"
  default = "api.example53.xyz"
}
```

Add a `route53.tf` file:

```terraform
resource "aws_route53_zone" "prod" {
  name = var.prod_base_domain
}

resource "namecheap_domain_records" "prod" {
  domain = var.prod_base_domain
  mode   = "OVERWRITE"

  nameservers = [
    aws_route53_zone.prod.name_servers[0],
    aws_route53_zone.prod.name_servers[1],
    aws_route53_zone.prod.name_servers[2],
    aws_route53_zone.prod.name_servers[3],
  ]
}
```

Run `terraform apply`. Check nameservers on Namecheap:

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

### Creating SSL Certificate

Now, let's create an SSL certificate and set up DNS A record for `api.example53.xyz` domain.

Add to the `route53.tf` following code:

```terraform
...

resource "aws_acm_certificate" "prod_backend" {
  domain_name       = var.prod_backend_domain
  validation_method = "DNS"
}

resource "aws_route53_record" "prod_backend_certificate_validation" {
  for_each = {
    for dvo in aws_acm_certificate.prod_backend.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = aws_route53_zone.prod.zone_id
}

resource "aws_acm_certificate_validation" "prod_backend" {
  certificate_arn         = aws_acm_certificate.prod_backend.arn
  validation_record_fqdns = [for record in aws_route53_record.prod_backend_certificate_validation : record.fqdn]
}

resource "aws_route53_record" "prod_backend_a" {
  zone_id = aws_route53_zone.prod.zone_id
  name    = var.prod_backend_domain
  type    = "A"

  alias {
    name                   = aws_lb.prod.dns_name
    zone_id                = aws_lb.prod.zone_id
    evaluate_target_health = true
  }
}
```

Here we are going to create a new [SSL certificate](https://aws.amazon.com/certificate-manager/) for `api.example53.xyz`, validate the SSL certificate via [DNS CNAME](https://docs.aws.amazon.com/acm/latest/userguide/dns-validation.html) record, and [add DNS A record to Load Balancer](https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/routing-to-elb-load-balancer.html). 

Apply changes with `terraform apply` and wait for certificate validation. Usually, it takes up to several minutes. But in some cases, it can take several hours. You can check more info [here](https://aws.amazon.com/blogs/security/easier-certificate-validation-using-dns-with-aws-certificate-manager/).   

### Redirecting HTTP to HTTPS

Now let's use the issued SSL certificate to enable HTTPS. Replace the `resource "aws_lb_listener" "prod_http"` block in the `load_balancer.tf` with the following code: 

```terraform
# Target listener for http:80
resource "aws_lb_listener" "prod_http" {
  load_balancer_arn = aws_lb.prod.id
  port              = "80"
  protocol          = "HTTP"
  depends_on        = [aws_lb_target_group.prod_backend]

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

# Target listener for https:443
resource "aws_alb_listener" "prod_https" {
  load_balancer_arn = aws_lb.prod.id
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  depends_on        = [aws_lb_target_group.prod_backend]

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.prod_backend.arn
  }

  certificate_arn = aws_acm_certificate_validation.prod_backend.certificate_arn
}
```

Here we redirect unsecured HTTP traffic to HTTPS and add a listener for the HTTPS port. Apply changes and check [https://api.example53.xyz](https://api.example53.xyz) URL. You should see Django starting page. 

### Setting up the ALLOWED_HOSTS variable 

Now, let's provide the [`ALLOWED_HOSTS`](https://docs.djangoproject.com/en/3.2/ref/settings/#std-setting-ALLOWED_HOSTS) setting to the Django app. It's important to prevent [HTTP Host header attacks](https://docs.djangoproject.com/en/3.2/topics/security/#host-headers-virtual-hosting). So, Django Application should only accept our domain `api.example53.xyz` in the host header. 

Now Django accepts any domain, for example, Load Balancer's domain. Visit `https://prod-1222631842.us-east-2.elb.amazonaws.com` to check this fact. You can ignore the warning about an invalid SSL Certificate and see that Django responds to this host. 

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


Also, let's disable a Debug mode and remove the `SECRET_KEY` value from the code to improve security. Add the `TF_VAR_prod_backend_secret_key` variable with a random generated value to the `.env`, run `export $(cat .env | xargs)`, and specify this var in `variables.tf`:

```terraform
variable "prod_backend_secret_key" {
  description = "production Django's SECRET_KEY"
}
```

Next, pass the domain name and `SECRET_KEY` in `ecs.tf`, set up `SECRET_KEY`, `DEBUG`, and `ALLOWED_HOSTS` variables in `backend_container.json.tpl` and apply changes:

```terraform
locals {
  container_vars = {
    ...
    
    domain = var.prod_backend_domain
    secret_key = var.prod_backend_secret_key
  }
}
```

```json
"environment": [
  ...
  {
    "name": "SECRET_KEY",
    "value": "${secret_key}"
  },
  {
    "name": "DEBUG",
    "value": "off"
  },
  {
    "name": "ALLOWED_HOSTS",
    "value": "${domain}"
  }
],
```

Now we have all necessary environment variables on ECS. Move to the Django app and change `settings.py`:

```python
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env("SECRET_KEY", default="ewfi83f2ofee3398fh2ofno24f")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env("DEBUG", cast=bool, default=True)

ALLOWED_HOSTS = env("ALLOWED_HOSTS", cast=list, default=["*"])
```

Here we receive `SECRET_KEY`, `DEBUG`, and `ALLOWED_HOSTS` variables from env variables. We provide default `SECRET_KEY` to allow running the application locally without specifying `SECRET_KEY` in the `.env` file. 

### Health Check

All user's requests would have Host header `api.example53.xyz`. But, we also have [health check requests](https://docs.aws.amazon.com/elasticloadbalancing/latest/network/target-group-health-checks.html) from a load balancer. 

AWS load balancers can automatically check our container's health. If the container responds correctly, the load balancer considers that target is *healthy*. Otherwise, the target will be marked as *unhealthy*. Load balancer routes traffic to *healthy* targets only. Thus, user requests wouldn't hit *unhealthy* containers.

> For HTTP or HTTPS health check requests, the host header contains the IP address of the load balancer node and the listener port, not the IP address of the target and the health check port.

We don't know the Load Balancer IP address. Also, this IP can be changed after some time. Therefore, we cannot add the Load Balancer host to the `ALLOWED_HOSTS`. 

The solution is to write a custom [middleware](https://docs.djangoproject.com/en/3.2/topics/http/middleware/) that returns a successful response before the host checking in the `SecurityMiddleware`.

First, go to the infrastructure, change the health check URL in `load_balancer.tf` to `/health/`, and apply changes:

```terraform
resource "aws_lb_target_group" "prod_backend" {
  ...
  health_check {
    path = "/health/"
    ...
  }
}
```

Return to the Django project and create `django_aws/middleware.py`:

```python
from django.http import HttpResponse
from django.db import connection


def health_check_middleware(get_response):
    def middleware(request):
        # Health-check request
        if request.path == "/health/":
            # Check DB connection is healthy
            with connection.cursor() as cursor:
                cursor.execute("SELECT 1")

            return HttpResponse("Healthy!")

        # Regular requests
        return get_response(request)

    return middleware
```

Add this middleware to the `settings.py` before the `SecurityMiddleware`: 


```python
MIDDLEWARE = [
    'django_aws.middleware.health_check_middleware',
    'django.middleware.security.SecurityMiddleware',
    ...
]
```

Run `python manage.py runserver` and check `127.0.0.1:8000/health/` URL in your browser. You should see the text response `Healthy!`.

Commit and push changes, wait for the pipeline and check the Load Balancer domain again `https://prod-57218461274.us-east-2.elb.amazonaws.com/`. Now, we get a `Bad Request` error. Also, we didn't see a traceback or other debug information, so we can be sure that the debug mode is disabled.

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

Also, navigate to `https://prod-57218461274.us-east-2.elb.amazonaws.com/health/` in your browser to check `health_check_middleware`. We get the `Healthy!` response. So, the  Load Balancer will be able to check containers' health without providing the correct Host header.

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

Congratulations! We've successfully set up a domain name, created health checks, disabled the debug mode, and removed `SECRET_KEY` value from the source code. Do not forget to push infrastructure code to GitLab.

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_4) or connect with me directly on [LinkedIn](https://www.linkedin.com/in/yevhen-bondar/).