DigitalOcean Terraform Module

Provision a Flo node on DigitalOcean with Terraform — droplet, firewall, cloud-init, and optional cluster wiring.

The terraform-digitalocean-flo module provisions a production-ready Flo node on DigitalOcean. It handles the droplet, firewall, cloud-init installation, and optional cluster formation in a single terraform apply.

Registry: floruntime/flo/digitalocean

Quick Start

module "flo" {
  source  = "floruntime/flo/digitalocean"
  version = "~> 0.0.1"
 
  ssh_key_ids = [data.digitalocean_ssh_key.main.id]
  flo_version = "v0.1.0" # pin in production
}
 
output "endpoint"  { value = module.flo.listen_endpoint }
output "dashboard" { value = module.flo.dashboard_url }
terraform init
terraform apply
 
# Point the CLI at your new node
flo -e "$(terraform output -raw listen_endpoint)" kv set hello world

What It Provisions

ResourcePurpose
DropletUbuntu 24.04 LTS, configurable size/region
FirewallInbound rules for SSH, wire protocol, dashboard, and cluster ports
Cloud-initDownloads Flo via scripts/install.sh, writes flo.toml, starts flo.service
Project attachmentOptional — adds the droplet to a DigitalOcean project

The cloud-init script runs once per droplet. It:

  1. Installs curl and ca-certificates if needed
  2. Downloads and installs flo via the install script (optionally pinned to a version)
  3. Creates a flo system user
  4. Writes /etc/flo/flo.toml from the module inputs
  5. Creates and chowns the data directory
  6. Writes and enables a systemd unit (flo.service)

Port Layout

Flo derives all secondary ports from listen_port:

ServiceDefaultFormula
Wire protocol9000listen_port
Prometheus metrics9001listen_port + 1
Dashboard / REST API9002listen_port + 2
Peer port (Raft between members)9500listen_port + 500

The firewall automatically opens the correct ports based on your settings:

  • Wire protocol — always open, gated by api_allowed_cidrs
  • Dashboard — open when enable_dashboard = true, gated by dashboard_allowed_cidrs
  • Metrics — open only when expose_metrics = true
  • Peer port — open when cluster_enabled = true, gated by cluster_allowed_cidrs

Single Node

The simplest deployment — one droplet with all defaults:

main.tf
terraform {
  required_version = ">= 1.5.0"
  required_providers {
    digitalocean = { source = "digitalocean/digitalocean", version = ">= 2.40" }
  }
}
 
variable "do_token"     { type = string, sensitive = true }
variable "ssh_key_name" { type = string }
 
provider "digitalocean" {
  token = var.do_token
}
 
data "digitalocean_ssh_key" "main" {
  name = var.ssh_key_name
}
 
module "flo" {
  source  = "floruntime/flo/digitalocean"
  version = "~> 0.0.1"
 
  ssh_key_ids = [data.digitalocean_ssh_key.main.id]
 
  environment = "prod"
  region      = "lon1"
  flo_version = "v0.1.0"
}
 
output "ipv4_address"  { value = module.flo.ipv4_address }
output "listen_endpoint" { value = module.flo.listen_endpoint }
output "dashboard_url"   { value = module.flo.dashboard_url }
terraform apply -var-file=variables.tfvars
 
flo -e "$(terraform output -raw listen_endpoint)" kv set hello world
open "$(terraform output -raw dashboard_url)"

3-Node Cluster

Provision three droplets: node 1 is the first member, nodes 2 and 3 join it at its peer port. A reserved IP for node 1 keeps the seed address stable from the first apply. Every member proves the same cluster_secret, and a cluster runs one shard. The provider, the variables and data.digitalocean_ssh_key.main are declared as in the single-node example above, plus variable "cluster_secret" { type = string, sensitive = true }. The cluster_first_member input arrives with module version 0.1.0.

main.tf
locals {
  node_ids    = [1, 2, 3]
  listen_port = 9000
  peer_port   = local.listen_port + 500
}
 
# Reserve a stable IP for each node
resource "digitalocean_reserved_ip" "node" {
  for_each = toset([for id in local.node_ids : tostring(id)])
  region   = "lon1"
}
 
module "flo" {
  for_each = toset([for id in local.node_ids : tostring(id)])
  source   = "floruntime/flo/digitalocean"
  version  = "~> 0.1.0"
 
  ssh_key_ids = [data.digitalocean_ssh_key.main.id]
 
  environment = "prod"
  region      = "lon1"
  flo_version = "v0.1.0"
  listen_port = local.listen_port
  shards      = 1
 
  cluster_enabled      = true
  cluster_node_id      = tonumber(each.key)
  cluster_first_member = each.key == "1"
  cluster_seeds        = each.key == "1" ? [] : ["${digitalocean_reserved_ip.node["1"].ip_address}:${local.peer_port}"]
  cluster_secret       = var.cluster_secret
}
 
# Attach each reserved IP to its droplet
resource "digitalocean_reserved_ip_assignment" "node" {
  for_each   = module.flo
  ip_address = digitalocean_reserved_ip.node[each.key].ip_address
  droplet_id = each.value.droplet_id
}
 
output "endpoints" {
  value = {
    for id, m in module.flo :
    id => { listen = "${digitalocean_reserved_ip.node[id].ip_address}:${local.listen_port}" }
  }
}

After apply, point your CLI at any node:

flo -e "$(terraform output -json endpoints | jq -r '."1".listen')" \
  kv set cluster ok

Node 1 leads a group of one until nodes 2 and 3 have joined it; a restarted member keeps its role from its own data, so the same configuration applies on every later apply.

Configuration Reference

Required

VariableTypeDescription
ssh_key_idslist(string)DigitalOcean SSH key IDs or fingerprints. At least one required.

Naming & Placement

VariableTypeDefaultDescription
environmentstring"dev"Label used in droplet name and tags
regionstring"lon1"DigitalOcean region slug
droplet_sizestring"s-2vcpu-4gb"Droplet size. Flo benefits from multiple vCPUs (one shard per CPU)
imagestring"ubuntu-24-04-x64"Base image slug
tagslist(string)[]Extra droplet tags
project_idstring""Optional DO project ID

Droplet Features

VariableTypeDefaultDescription
enable_backupsboolfalseWeekly droplet backups
enable_monitoringbooltrueDO monitoring agent
enable_ipv6booltrueEnable IPv6

Firewall

VariableTypeDefaultDescription
create_firewallbooltrueCreate a DO firewall. Disable if you manage firewalls externally
ssh_allowed_cidrslist(string)["0.0.0.0/0", "::/0"]CIDRs allowed to reach SSH
api_allowed_cidrslist(string)["0.0.0.0/0", "::/0"]CIDRs allowed to reach the wire-protocol port
dashboard_allowed_cidrslist(string)["0.0.0.0/0", "::/0"]CIDRs allowed to reach the dashboard. Restrict in production
metrics_allowed_cidrslist(string)["0.0.0.0/0", "::/0"]CIDRs for Prometheus (only when expose_metrics = true)
expose_metricsboolfalseOpen the metrics port on the firewall
cluster_allowed_cidrslist(string)["0.0.0.0/0", "::/0"]CIDRs allowed to reach the peer port. Restrict to the cluster's network
extra_inbound_tcp_portslist(number)[]Additional TCP ports to open

Flo Installation

VariableTypeDefaultDescription
flo_versionstring""Release tag for install.sh. Empty = latest. Pin in production

Flo Runtime

VariableTypeDefaultDescription
listen_portnumber9000Wire-protocol port. Metrics = +1, Dashboard = +2
bind_addressstring"0.0.0.0"Bind address for the wire listener
data_dirstring"/var/lib/flo"Data directory. Created and chowned by cloud-init
shardsnumber0Number of shards. 0 = auto-detect from CPU count
durabilitystring"async_flush"Storage durability: sync, async_flush, or ephemeral
hot_buffer_capacitynumber0In-memory hot buffer size in bytes. 0 = default
log_levelstring"info"Log level
enable_metricsbooltrueEnable Prometheus metrics endpoint
enable_dashboardbooltrueEnable dashboard HTTP API + web UI
dashboard_bind_addressstring"0.0.0.0"Bind address for the dashboard

Cluster

VariableTypeDefaultDescription
cluster_enabledboolfalseMake this droplet a cluster member (first member or joiner)
cluster_first_memberboolfalseThis droplet starts the cluster and leads alone until others join; leave cluster_seeds empty
cluster_node_idnumber0This node's id in the cluster (1, 2, 3, …). 0 derives one from hostname and port, which collides on cloned images
cluster_seedslist(string)[]Peer ports of members to join (host:listen_port + 500); required unless cluster_first_member = true
cluster_secretstring (sensitive)""The secret every member proves at the peer port; required when cluster_enabled = true

Persistent Storage

By default Flo writes to the droplet's root disk, which means a terraform apply -replace=... wipes all data. Set volume_size > 0 to provision a DigitalOcean block-storage volume, attach it at boot, and mount it at data_dir. The volume carries prevent_destroy = true, so it survives droplet replacement.

VariableTypeDefaultDescription
volume_sizenumber0Volume size in GB. 0 keeps data on the root disk
volume_namestring""Volume name. Empty auto-derives <droplet>-data
volume_filesystem_typestring"ext4"Filesystem for new volumes: ext4 or xfs. Re-attached volumes keep their existing filesystem
module "flo" {
  source  = "floruntime/flo/digitalocean"
  version = "~> 0.0.2"
 
  ssh_key_ids = [data.digitalocean_ssh_key.main.id]
  flo_version = "v0.1.0"
 
  volume_size = 50  # GB — survives droplet replacement
}

Outputs

OutputDescription
droplet_idDigitalOcean droplet ID
droplet_nameDroplet name
ipv4_addressPublic IPv4 address
ipv4_address_privatePrivate VPC IPv4 address
ipv6_addressPublic IPv6 address (empty if disabled)
urnDroplet URN
listen_endpointhost:port for the CLI and SDKs
dashboard_urlDashboard URL (empty when disabled)
metrics_endpointPrometheus metrics URL (empty when disabled)
peer_endpointhost:peer_port — what joining nodes list in cluster_seeds
raft_portPeer port (listen_port + 500)
firewall_idFirewall ID (empty when create_firewall = false)
volume_idVolume ID (empty when volume_size = 0)
volume_nameVolume name (empty when volume_size = 0)
volume_urnVolume URN (empty when volume_size = 0)

Operational Notes

Service management

Flo runs as the flo system user under systemd:

ssh root@$(terraform output -raw ipv4_address)
 
# View logs
journalctl -u flo -f
 
# Restart
systemctl restart flo
 
# Check status
systemctl status flo

Data directory

Data lives in /var/lib/flo (owned by flo:flo, mode 0750). The systemd unit has ProtectSystem=strict with only ReadWritePaths=<data_dir> — Flo cannot write elsewhere on the filesystem. When volume_size > 0, that path is the mount point for the attached block-storage volume; cloud-init formats blank volumes and adds an /etc/fstab entry with nofail,discard so the mount survives reboots.

Rolling config changes

Cloud-init runs only on first boot. To roll a new config:

In-place (no droplet replacement):

ssh root@<ip> 'vim /etc/flo/flo.toml && systemctl restart flo'

Immutable (replace the droplet):

terraform apply -replace=module.flo.digitalocean_droplet.this

Resizing

To resize a running droplet:

terraform apply -var="droplet_size=s-4vcpu-8gb"

This triggers a DO resize (which power-cycles the droplet). No data loss — Flo replays its log on restart.

Dashboard security

The dashboard exposes administrative APIs. Never leave dashboard_allowed_cidrs open to 0.0.0.0/0 in production. Use one of:

# Option 1: Restrict to your office/home IP
dashboard_allowed_cidrs = ["203.0.113.5/32"]
 
# Option 2: Bind to private interface, access via SSH tunnel
dashboard_bind_address = "10.0.0.2"  # private VPC IP
 
# Then: ssh -L 9002:localhost:9002 root@<ip>

Metrics scraping

The metrics port is not opened on the firewall by default. To scrape Prometheus metrics externally:

expose_metrics         = true
metrics_allowed_cidrs  = ["10.0.0.0/16"] # only your VPC

Or scrape locally via the DigitalOcean monitoring agent (which runs on-loopback).

Upgrading Flo

To upgrade Flo on an existing droplet:

# SSH in and run the installer with the new version
ssh root@<ip> 'curl -fsSL https://raw.githubusercontent.com/floruntime/flo/master/scripts/install.sh | sh -s -- --version v0.2.0'
 
# Restart
ssh root@<ip> 'systemctl restart flo'

For a clean rebuild, bump flo_version and replace the droplet:

terraform apply -var="flo_version=v0.2.0" -replace=module.flo.digitalocean_droplet.this

Publishing to the Terraform Registry

The module is designed for the public Terraform Registry. It lives in its own repo at github.com/floruntime/terraform-digitalocean-flo and follows the terraform-<PROVIDER>-<NAME> naming convention. Once pushed and tagged, the registry discovers it automatically — consumers reference it as:

source  = "floruntime/flo/digitalocean"
version = "~> 0.0.1"

Next Steps