Kamal to Digital Ocean with Open Tofu (Terraform)
Deploying Rails a application to Digital Ocean using Kamal and Infrastructure as Code with Open Tofu (Terraform)
📹 Watch the Development Process
Watch the whole video at: youtube.com/watch?v=5NHOwOTCn_k
Source code: github.com/stocklight/insidertrades.directory
🎯 What We'll Cover
This guide walks you through setting up a Ruby on Rails server on Digital Ocean. The server is provisioned using Open Tofu (Terraform) so that the infrastructure is documented in code and the application is deployed with Kamal meaning no AWS, Heroku or PAAS needed.
🛠️ Technologies
- • Kamal for application deployment
- • Digital Ocean Ruby on Rails hosting
- • Cloudflare DNS hosting
- • Open Tofu for infrastructure as code (like Terraform)
🎯 What You'll Learn
- • Ruby on Rails on a Digital Ocean droplet
- • Configuring Cloudflare DNS
- • Infrastructure provisioning with Open Tofu (Terraform)
📋 Prerequisites
- Domain name registered with Cloudflare
- Docker.com account
- Paid Digital Ocean account
tofu destroy
later
🛠 Technical Walk-through
1. Prepare API tokens as Open Tofu (Terraform) environment variables
Before we can deploy our Ruby on Rails application to Digital Ocean, we need to set up the necessary API tokens as environment variables for Open Tofu to be able to terraform the infrastructure:
Digital Ocean API Token
Generate a Digital Ocean Personal Access (API) token per these docs (13:00 in the video) with no expiry date and the following custom scopes:
• block_storage
• block_storage_action
• droplet
• firewall
• ssh_key
• tag
then set it as an environment variable so it can be terraformed by the digital-ocean.tf script below:
export TF_VAR_digital_ocean_token="your-digital-ocean-api-token"
Cloudflare API Token
Create a Cloudflare (API) token with Edit zone DNS permission and export it as an environment variable so it can be terraformed by the cloudflare.tf script below:
export TF_VAR_cloudflare_token="your-cloudflare-api-token"
State Encryption Passphrase
Set a secure passphrase for encrypting your Open Tofu (Terraform) state files and store this somewhere safe:
# eg. `openssl rand -base64 32`
export TF_VAR_state_encryption_passphrase="your-secure-passphrase"
Docker Hub Account
Finally, create a Docker Hub account and set your password as an environment variable so it can be read by .kamal/secrets when rails is deploying:
export KAMAL_REGISTRY_PASSWORD="your-docker-hub-password"
2. Provision a Digital Ocean Droplet via Open Tofu (Terraform)
We'll use OpenTofu (an open-source Terraform fork) to provision our infrastructure.
- Exposing ICMP (ping) in the firewall - this caused the DNS resolution to flake a bit before the change
- Provisioning a dedicated digital ocean volume - the original script tried to get away with just the 10gb of storage on the droplet however that caused the rails 8 app to crash consistently due to insufficient memory.
- 1gb of RAM on the droplet ie. 's-1vcpu-1gb' because the minimum droplet specs on digital ocean of s-1vcpu-512mb-10gb were causing the rails 8 app to swap to disk due to insufficient memory
Create Terraform Configuration Files
First, let's create our Terraform configuration files so that we can commit it to source control without exposing sensitive information:
state-encryption.tf
variable "state_encryption_passphrase" {
description = "Passphrase for encrypting Open Tofu state files"
type = string
sensitive = true
}
terraform {
required_version = ">= 1.0"
required_providers {
digitalocean = {
source = "digitalocean/digitalocean"
version = "~> 2.40"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
encryption {
key_provider "pbkdf2" "state_key" {
passphrase = var.state_encryption_passphrase
key_length = 32
salt_length = 32
hash_function = "sha256"
iterations = 600000
}
method "aes_gcm" "state_method" {
keys = key_provider.pbkdf2.state_key
}
state {
method = method.aes_gcm.state_method
}
}
}
now, let's create the Digital Ocean droplet with an attached volume of storage and a firewall and output the public IP address and private SSH key:
digital-ocean.tf
variable "digital_ocean_token" {
description = "Digital Ocean API token"
type = string
sensitive = true
}
provider "digitalocean" {
token = var.digital_ocean_token
}
resource "tls_private_key" "kamal_deploy" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "digitalocean_ssh_key" "kamal_deploy" {
name = "insidertrades-directory-kamal-deploy-key"
public_key = tls_private_key.kamal_deploy.public_key_openssh
}
resource "digitalocean_droplet" "web_droplet" {
image = "ubuntu-22-04-x64"
name = "insidertrades-directory-web-production"
region = "nyc1"
size = "s-1vcpu-1gb"
ssh_keys = [digitalocean_ssh_key.kamal_deploy.fingerprint]
provisioner "remote-exec" {
inline = [
"sudo apt update",
"sudo apt install -y -o DPkg::Lock::Timeout=60 docker.io",
"sudo systemctl start docker",
"sudo systemctl enable docker"
]
}
backups = true
monitoring = true
connection {
type = "ssh"
user = "root"
private_key = tls_private_key.kamal_deploy.private_key_pem
host = self.ipv4_address
}
tags = [
"insidertrades-directory",
"production",
"web"
]
}
resource "digitalocean_volume" "web_storage" {
region = "nyc1"
name = "insidertrades-directory-web-storage"
size = 30
initial_filesystem_type = "ext4"
description = "30GB volume for web application storage"
}
resource "digitalocean_volume_attachment" "web_storage_volume_attachment" {
droplet_id = "${digitalocean_droplet.web_droplet.id}"
volume_id = "${digitalocean_volume.web_storage.id}"
}
resource "digitalocean_firewall" "web_firewall" {
name = "insidertrades-directory-web-firewall-production"
droplet_ids = [digitalocean_droplet.web_droplet.id]
inbound_rule {
protocol = "tcp"
port_range = "22"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "80"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "tcp"
port_range = "443"
source_addresses = ["0.0.0.0/0", "::/0"]
}
inbound_rule {
protocol = "icmp"
source_addresses = ["0.0.0.0/0", "::/0"]
}
# Allow all outbound traffic
outbound_rule {
protocol = "tcp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "udp"
port_range = "1-65535"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
outbound_rule {
protocol = "icmp"
destination_addresses = ["0.0.0.0/0", "::/0"]
}
}
output "ip_address" {
description = "Public IP address of the web server"
value = digitalocean_droplet.web_droplet.ipv4_address
}
output "ssh_private_key" {
description = "Private SSH key for Kamal deployment (keep secure!)"
value = tls_private_key.kamal_deploy.private_key_pem
sensitive = true
}
Deploy Infrastructure with OpenTofu
Now let's provision our infrastructure:
brew install opentofu
mkdir opentofu
cd opentofu
tofu init .
tofu plan
tofu apply
and then configure SSH access to the droplet:
tofu output ip_address
tofu output -raw ssh_private_key > ~/.ssh/insidertrades_digital_ocean_deploy_key
chmod 600 ~/.ssh/insidertrades_digital_ocean_deploy_key
vi ~/.ssh/config
Add the following configuration replacing TODO-IPADDRESS with the actual IP from the open tofu output above:
~/.ssh/configHost TODO-IPADDRESS
User root
IdentityFile ~/.ssh/insidertrades_digital_ocean_deploy_key
IdentitiesOnly yes
Test your SSH connection:
ssh root@..ip-address...
3. Configure DNS with Cloudflare
Now let's configure DNS with Cloudflare so that our application is accessible via your registered domain name. First, copy this script to your opentofu directory,
replacing your-cloudflare-account-id
with your actual Cloudflare account ID per the instructions below:
cloudflare.tf
variable "cloudflare_token" {
description = "Cloudflare API token"
type = string
sensitive = true
}
provider "cloudflare" {
api_token = var.cloudflare_token
}
resource "cloudflare_zone" "insidertrades_directory_zone" {
name = "insidertrades.directory"
account = {
id = "your-cloudflare-account-id"
}
type = "full"
}
Before we can terraform the DNS record, Open Tofu needs to import the cloudflare zone, so run:
tofu import cloudflare_zone.insidertrades_directory_zone YOUR-CLOUDFLARE-ZONE-ID
Domain > Overview > Account ID
(13:00 in the video)
once that's out of the way, add the A record for your domain name to point to the digital ocean IP address to the bottom of your cloudflare.tf script so that it looks something like this:
resource "cloudflare_dns_record" "root_a_record" {
zone_id = cloudflare_zone.insidertrades_directory_zone.id
comment = "Root record for digital ocean server not google"
name = "insidertrades.directory" # ie. @
type = "A"
content = "206.81.6.23"
ttl = 1
proxied = false
}
and then run:
tofu apply
🥳 Well done, if everything went to plan, your server and DNS is now configured and you're ready to deploy your application with Kamal.
4. Deploy Application with Kamal
Generate your production credentials:
bin/rails credentials:edit --environment production
then edit .kamal/secrets to point to your generated key
.kamal/secrets...
RAILS_MASTER_KEY=$(cat config/credentials/production.key)
then update your kamal deployment configurations in config/deploy.yml with your:
- docker.com username and image location
- IP address of your digital ocean droplet (from the tofu output above)
- domain name
image: insidertradesdirectory/insidertrades_directory
registry:
username: insidertradesdirectory
proxy:
host: insidertrades.directory
and finally, deploy your Rails application 🚀:
kamal deploy