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