10 min read + 19 min video Published September 30, 2025

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
🎯 What You'll Learn
  • • Ruby on Rails on a Digital Ocean droplet
  • • Configuring Cloudflare DNS
  • • Infrastructure provisioning with Open Tofu (Terraform)

📋 Prerequisites

⚠️ Note: this hosting package costs ~$5 per month and can be instantly de-provisioned by issuing 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"
⚠️ Note: An advantage of Open Tofu compared to Terraform is that you can encrypt the state configuration and store it directly in source control (eg. github) as compared to Terraform which requires you to configure S3, GCS or HCP as a separate dependency.

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.

⚠️ Note: The video was missing a few things that have been updated in the scripts below, importantly:
  • 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
Lesson learnt: 1gb of ram and 30gb volume storage are minimum specs for a rails 8 app on digital ocean

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/config
Host 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
⚠️ Note: You'll find your cloudflare zone ID at 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
eg.
image: insidertradesdirectory/insidertrades_directory

registry:
  username: insidertradesdirectory

proxy:
  host: insidertrades.directory

and finally, deploy your Rails application 🚀:

kamal deploy
🎉 Infrastructure Ready! Your Digital Ocean droplet is now provisioned with a secure firewall, kamal deployment via docker and your rails app should be live and accessible via your domain name. This websdite (insidertrades.directory) was created using this demo.
Related Posts
Sep 22, 2025
Kamal to Digital Ocean with Open Tofu (Terraform)
Coming Soon
Exception Notifier with Slack integration
Coming Soon
Turbolinks / View Component, no webpacker node or js
← Back to All Posts