Skip to content

DigitalOcean Terraform Module

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

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 }
Terminal window
terraform init
terraform apply
# Point the CLI at your new node
flo --server "$(terraform output -raw listen_endpoint)" kv set hello world
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)

Flo derives all secondary ports from listen_port:

ServiceDefaultFormula
Wire protocol9000listen_port
Prometheus metrics9001listen_port + 1
Dashboard / REST API9002listen_port + 2
Raft replication9500listen_port + 500
SWIM gossip9600listen_port + 600

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
  • Raft + Gossip — open when cluster_enabled = true, gated by cluster_allowed_cidrs

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" }
}
}
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 }
Terminal window
terraform apply -var-file=variables.tfvars
flo --server "$(terraform output -raw listen_endpoint)" kv set hello world
open "$(terraform output -raw dashboard_url)"

Provision three droplets wired together via reserved IPs so the gossip seed list is stable from the first apply:

main.tf
locals {
node_ids = [1, 2, 3]
listen_port = 9000
gossip_port = local.listen_port + 600
}
# Reserve stable IPs for each node
resource "digitalocean_reserved_ip" "node" {
for_each = toset([for id in local.node_ids : tostring(id)])
region = "lon1"
}
# Build the seed list from those IPs
locals {
seeds = [
for id in local.node_ids :
"${digitalocean_reserved_ip.node[tostring(id)].ip_address}:${local.gossip_port}"
]
}
module "flo" {
for_each = toset([for id in local.node_ids : tostring(id)])
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"
listen_port = local.listen_port
cluster_enabled = true
cluster_node_id = tonumber(each.key)
cluster_seeds = local.seeds
}
# 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
}

After apply, point your CLI at any node:

Terminal window
flo --server "$(terraform output -json listen_endpoints | jq -r '."1"')" \
kv set cluster ok

The cluster bootstraps automatically — the first node to come up forms the cluster, and the rest join via the seed list.

VariableTypeDescription
ssh_key_idslist(string)DigitalOcean SSH key IDs or fingerprints. At least one required.
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
VariableTypeDefaultDescription
enable_backupsboolfalseWeekly droplet backups
enable_monitoringbooltrueDO monitoring agent
enable_ipv6booltrueEnable IPv6
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 for Raft and gossip traffic
extra_inbound_tcp_portslist(number)[]Additional TCP ports to open
VariableTypeDefaultDescription
flo_versionstring""Release tag for install.sh. Empty = latest. Pin in production
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: async_flush, sync_flush, or fsync
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
VariableTypeDefaultDescription
cluster_enabledboolfalseJoin this droplet to a Flo cluster
cluster_node_idnumber0Unique node ID within the cluster (1, 2, 3, …)
cluster_seedslist(string)[]Gossip seed addresses (host:gossip_port)

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.me.id]
flo_version = "v0.1.0"
volume_size = 50 # GB — survives droplet replacement
}
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)
gossip_endpointhost:gossip_port — use in other nodes’ cluster_seeds
raft_portRaft replication port
gossip_portSWIM gossip port
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)

Flo runs as the flo system user under systemd:

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

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.

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

In-place (no droplet replacement):

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

Immutable (replace the droplet):

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

To resize a running droplet:

Terminal window
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.

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>

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

To upgrade Flo on an existing droplet:

Terminal window
# 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:

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

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"