This how-to will help you deploy a production-ready infrastructure on Digital Ocean using Terraform.
Pre-requisites
- Install Terraform
- Create a Digital Ocean account if you don't already have one (Use this link to get $100 credit)
- Generate a Personal Access Token for your Digital Ocean account to access the DigitalOcean API. Go to
API => Tokens/Keys => Generate New Token
. Save thestring
generated. - 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 calleddo_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 fileterraform.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>
- 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.
👍
Top comments (2)
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.I totally agree with you, thanks for pointing this out. 👏