DEV Community

Cover image for How to build a minimal production-ready infrastructure with Terraform on DigitalOcean
Abubakar Hassan
Abubakar Hassan

Posted on

How to build a minimal production-ready infrastructure with Terraform on DigitalOcean

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

Pre-requisites

  1. Install Terraform
  2. Create a Digital Ocean 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 .
Enter fullscreen mode Exit fullscreen mode
  • Create the following terraform files:
$ touch versions.tf
$ touch main.tf
$ touch variables.tf
Enter fullscreen mode Exit fullscreen mode
  • In versions.tf, specify the Digital Ocean terraform provider as follows:
terraform {
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = "2.25.2"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
  • In main.tf, add the token required by the provider like this:
provider "digitalocean" {
  token = var.do_token
}
Enter fullscreen mode Exit fullscreen mode
  • In variables.tf, create a new variable called do_token.
variable "do_token" {
  type        = string
  description = "Digital Ocean personal access token"
  default     = "<token_string>"
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
}
Enter fullscreen mode Exit fullscreen mode
  • Run the command below to see the execution plan so far
$ terraform plan
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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"
Enter fullscreen mode Exit fullscreen mode
  • Add a value for the domain you want to use in data.tf
...

data "digitalocean_domain" "web" {
  name = var.domain_name
}
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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]
  }
}
Enter fullscreen mode Exit fullscreen mode
  • Now you can apply the files and let terraform create the infrastructure on Digital Ocean
$ terraform apply
Enter fullscreen mode Exit fullscreen mode

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

Screenshot of bastion host after logging in

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

Delete infrastructure

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

$ terraform destroy
Enter fullscreen mode Exit fullscreen mode

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.
👍

Oldest comments (2)

Collapse
 
j_mplourde profile image
Jean-Michel Plourde

Great article, this is well written and very informative.

On top of making sure to add .tfvars extension to your .gitinore, you should also make sure your tfstate file is encrypted. Because when Terraform use a secret for creating resources, it writes the value to the tfstate as plain-text.

Collapse
 
itssadon profile image
Abubakar Hassan

I totally agree with you, thanks for pointing this out. 👏