How to build a minimal production-ready infrastructure with Terraform on DigitalOcean
Tags: terraform, digitalocean, linux, devops
Published: 2022-12-11 23:21:32

This how-to will help you deploy a production-ready infrastructure on Digital Ocean using Terraform.

## Pre-requisites
1. Install [Terraform](https://www.terraform.io/downloads.html)
2. Create a [Digital Ocean](https://m.do.co/c/72deb6aa82de) account if you don't already have one (Use this link to get $100 credit)
3. Generate a Personal Access Token for your Digital Ocean account to access the DigitalOcean API. Go to `API => Tokens/Keys => Generate New Token`. Save the `string` generated.
4. Create a domain for your project. Go to `Networking => Domains => Add domain`

---

## Initial setup terraform files
- Open your terminal and create a new project directory, and open with you code editor.

```
$ mkdir minimal-prod`
$ cd minimal-prod`
$ code .
```

- Create the following terraform files:

```
$ touch versions.tf
$ touch main.tf
$ touch variables.tf
```

- In `versions.tf`, specify the Digital Ocean terraform provider as follows:

```
terraform {
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "2.25.2"
    }
  }
}
```

- In `main.tf`, add the token required by the provider like this:

```
provider "digitalocean" {
  token = var.do_token
}
```

- In `variables.tf`, create a new variable called `do_token`.

```
variable "do_token" {
  type        = string
  description = "Digital Ocean personal access token"
  default     = "<token_string>"
}
```

> If you wish to commit `variables.tf` to your version control system, you might want to use a different file for more sensitive information, such as your Digital Ocean's personal access token. Create a new file `terraform.tfvars` and save the following to it:

```
do_token = "<digital_ocean_token>"
```

>  Replace `<digital_ocean_token>` with the actual value generated.
> ⚠️ Make sure *.tfvars is in your .gitignore file.

- Now prepare your working directory for other commands by running the following command:

```
$ terraform init
```

---

## Architecture Diagram
Below is a diagram representing the architecture that will be produced by executing the Terraform files at the end of this tutorial. 

```
                       |                                                   
                     https                                                
                       |                                                   
                       v                                                    
              +--------------------+                                        
              |    Load Balancer   |                                        
 +--------------------------------------------------+                          
 |            |                    |                |                     
 |            +--------------------+                |                     
 |                     |                            |                     
 |                     |                            |                     
 |                    http              +---------+ |                     
 |                     |                |         | |                     
 |                     |                |         | |                     
 |                     +-------SSH------| Bastion |<---SSH---
 |                     |                |         | |                     
 |                     |                |         | |                     
 |                     |                +---------+ |                     
 |          +----------+---------+                  |                     
 |          |          |         |                  |                     
 |          v          v         v                  |                     
 |      +-------+  +-------+  +-------+             |                     
 |      |  web  |  |  web  |  |  web  |             |                     
 |      +---+---+  +---+---+  +---+---+             |                     
 |          |          |          |                 |                     
 |          |          v          |                 |                     
 |          |     +----------+    |                 |                     
 |          |     |          |    |                 |                     
 |          +---->| database |<---+                 |                     
 |                |          |                      |                     
 |                +----------+                      |                     
 +--------------------------------------------------+  
```

---

### Virtual Private Cloud (VPC) setup
- Create a file `network.tf` and add the following to build the VPC

```
resource "digitalocean_vpc" "web" {
  name     = "${var.name}-vpc"
  region   = var.region
  ip_range = var.ip_range
}
```

- Add new variables to `variables.tf`

```
...

variable "name" {
  type        = string
  description = "Infrastructure project name"
  default     = "minimal-prod"
}

variable "region" {
  type    = string
  default = "ams2"
}

variable "ip_range" {
  type        = string
  description = "IP range for VPC"
  default     = "192.168.22.0/24"
}
```

- Run the command below to see the execution plan so far

```
$ terraform plan
```

---

### Web Servers setup

- Add a few more variables to `variables.tf` to be used in the future

```
...

variable "droplet_count" {
  type    = number
  default = 1
}

variable "image" {
  type        = string
  description = "OS to install on the servers"
  default     = "ubuntu-20-04-x64"
}

variable "droplet_size" {
  type    = string
  default = "s-1vcpu-1gb"
}

variable "ssh_key" {
  type = string
}

variable "subdomain" {
  type = string
}

variable "domain_name" {
  type = string
}
```

- Create a new file `data.tf` and add a data resource for our ssh key which will be pulled from Digital Ocean directly:

```
data "digitalocean_ssh_key" "main" {
  name = var.ssh_key
}
```

- Create a file `servers.tf` hold all the resources we'll be creating for our web servers as follows:

```
resource "digitalocean_droplet" "web" {
  count     = var.droplet_count
  image     = var.image
  name      = "web-${var.name}-${var.region}-${count.index + 1}"
  region    = var.region
  size      = var.droplet_size
  ssh_keys  = [data.digitalocean_ssh_key.main.id]
  vpc_uuid  = digitalocean_vpc.web.id
  tags      = ["${var.name}-webserver"]
  user_data = <<EOF
  #cloud-config
  packages:
    - nginx
    - postgresql
    - postgresql-contrib
  runcmd:
    - [ sh, -xc, "echo '<h1>web-${var.region}-${count.index + 1}</h1>' >> /var/www/html/index.html"]
  EOF
  lifecycle {
    create_before_destroy = true
  }
}
```

- Update `terraform.tfvars` with the following variables for our environment:

```
...
region        = "ams3"
droplet_count = 3
ssh_key       = "<ssh_key_name_on_digitalocean>"
domain_name   = "<domain_added_on_digitalocean>"
subdomain     = "app"
```

- Add a value for the domain you want to use in `data.tf`

```
...

data "digitalocean_domain" "web" {
  name = var.domain_name
}
```

- Create a lets encrypt certificate to be used by the load balancer. Add the following to the `servers.tf` file

```
resource "digitalocean_certificate" "web" {
  name    = "${var.name}-certificate"
  type    = "lets_encrypt"
  domains = ["${var.subdomain}.${data.digitalocean_domain.web.name}"]
  lifecycle {
    create_before_destroy = true
  }
}
```

> You can use the Digital Ocean terraform provider to provide your own certificate if you want to.

- Next, we create our load balancer with the correct forwarding rules and the firewall setup in `servers.tf`. This is to block all inbound traffic directly to the web servers from the internet.

```
...

resource "digitalocean_loadbalancer" "web" {
  name                   = "web-${var.region}"
  region                 = var.region
  droplet_ids            = digitalocean_droplet.web.*.id
  vpc_uuid               = digitalocean_vpc.web.id
  redirect_http_to_https = true
  forwarding_rule {
    entry_port       = 443
    entry_protocol   = "https"
    target_port      = 80
    target_protocol  = "http"
    certificate_name = digitalocean_certificate.web.name
  }
  forwarding_rule {
    entry_port       = 80
    entry_protocol   = "http"
    target_port      = 80
    target_protocol  = "http"
    certificate_name = digitalocean_certificate.web.name
  }
  lifecycle {
    create_before_destroy = true
  }
}

resource "digitalocean_firewall" "web" {
  name        = "${var.name}-only-vpc-traffic"
  droplet_ids = digitalocean_droplet.web.*.id
  inbound_rule {
    protocol         = "tcp"
    port_range       = "1-65535"
    source_addresses = [digitalocean_vpc.web.ip_range]
  }
  inbound_rule {
    protocol         = "udp"
    port_range       = "1-65535"
    source_addresses = [digitalocean_vpc.web.ip_range]
  }
  inbound_rule {
    protocol         = "icmp"
    source_addresses = [digitalocean_vpc.web.ip_range]
  }
  outbound_rule {
    protocol              = "tcp"
    port_range            = "1-65535"
    destination_addresses = [digitalocean_vpc.web.ip_range]
  }
  outbound_rule {
    protocol              = "udp"
    port_range            = "1-65535"
    destination_addresses = [digitalocean_vpc.web.ip_range]
  }
  outbound_rule {
    protocol              = "icmp"
    destination_addresses = [digitalocean_vpc.web.ip_range]
  }
  outbound_rule {
    protocol              = "udp"
    port_range            = "53"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
  outbound_rule {
    protocol              = "tcp"
    port_range            = "80"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
  outbound_rule {
    protocol              = "tcp"
    port_range            = "443"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
  outbound_rule {
    protocol              = "icmp"
    destination_addresses = ["0.0.0.0/0", "::/0"]
  }
}
```

- Next, we create the record for the subdomain

```
...

resource "digitalocean_record" "web" {
  domain = data.digitalocean_domain.web.name
  type   = "A"
  name   = var.subdomain
  value  = digitalocean_loadbalancer.web.ip
  ttl    = 30
}
```

---

### Database resource

- Next, we add a few more variables to be used to setup our database in `variables.tf` as follows:

```
...

variable "db_count" {
  type    = number
  default = 1
}

variable "database_size" {
  type    = string
  default = "db-s-1vcpu-1gb"
}
```

- Next, create `database.tf` to build the database. For this example we will be creating a Postgres database.

```
resource "digitalocean_database_cluster" "postgres-cluster" {
  name                 = "${var.name}-database-cluster"
  engine               = "pg"
  version              = "11"
  size                 = var.database_size
  region               = var.region
  node_count           = var.db_count
  private_network_uuid = digitalocean_vpc.web.id
}

resource "digitalocean_database_firewall" "postgress-cluster-firewall" {
  cluster_id = digitalocean_database_cluster.postgres-cluster.id
  rule {
    type  = "tag"
    value = "${var.name}-webserver"
  }
}
```

---

### Jump server (Bastion) for accessing our infrastructure
- Next, we need to create a jump server (Bastion) to access our infrastructure. Create a `bastion.tf` with the following:

```
resource "digitalocean_droplet" "bastion" {
  image    = var.image
  name     = "bastion-${var.name}-${var.region}"
  region   = var.region
  size     = "s-1vcpu-1gb"
  ssh_keys = [data.digitalocean_ssh_key.main.id]
  vpc_uuid = digitalocean_vpc.web.id
  tags     = ["${var.name}-webserver"]
  lifecycle {
    create_before_destroy = true
  }
}

resource "digitalocean_record" "bastion" {
  domain = data.digitalocean_domain.web.name
  type   = "A"
  name   = "bastion-${var.name}-${var.region}"
  value  = digitalocean_droplet.bastion.ipv4_address
  ttl    = 300
}

resource "digitalocean_firewall" "bastion" {
  name        = "${var.name}-only-ssh-bastion"
  droplet_ids = [digitalocean_droplet.bastion.id]
  inbound_rule {
    protocol         = "tcp"
    port_range       = "22"
    source_addresses = ["0.0.0.0/0", "::/0"]
  }
  outbound_rule {
    protocol              = "tcp"
    port_range            = "22"
    destination_addresses = [digitalocean_vpc.web.ip_range]
  }
  outbound_rule {
    protocol              = "icmp"
    destination_addresses = [digitalocean_vpc.web.ip_range]
  }
}
```

- Now you can apply the files and let terraform create the infrastructure on Digital Ocean

```
$ terraform apply
```

> You can use the `--auto-approve` flag to let terraform automatically continue without asking you for approval.

---

### Access the server through the bastion host

- Copy the domain name of the bastion host and ssh into it from your terminal.

```
$ ssh -A root@<FQDN of bastion host>
```

![Screenshot of bastion host after logging in](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qc95exma5x0z43n40dmk.png)

- Copy the IP address of any of the web servers and ssh into it from the bastion host

```
$ ssh root@<private_ip_address_of_server>
```

---

### Delete infrastructure

This will undo everything. You can delete the infrastructure by running the following command:

```
$ terraform destroy
```

That's it.

**Thanks for the time reading this!**

Let me know if you there's a way you think this infrastructure can be improved.
👍