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
Quick Start
Section titled “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 initterraform apply
# Point the CLI at your new nodeflo --server "$(terraform output -raw listen_endpoint)" kv set hello worldWhat It Provisions
Section titled “What It Provisions”| Resource | Purpose |
|---|---|
| Droplet | Ubuntu 24.04 LTS, configurable size/region |
| Firewall | Inbound rules for SSH, wire protocol, dashboard, and cluster ports |
| Cloud-init | Downloads Flo via scripts/install.sh, writes flo.toml, starts flo.service |
| Project attachment | Optional — adds the droplet to a DigitalOcean project |
The cloud-init script runs once per droplet. It:
- Installs
curlandca-certificatesif needed - Downloads and installs flo via the install script (optionally pinned to a version)
- Creates a
flosystem user - Writes
/etc/flo/flo.tomlfrom the module inputs - Creates and chowns the data directory
- Writes and enables a systemd unit (
flo.service)
Port Layout
Section titled “Port Layout”Flo derives all secondary ports from listen_port:
| Service | Default | Formula |
|---|---|---|
| Wire protocol | 9000 | listen_port |
| Prometheus metrics | 9001 | listen_port + 1 |
| Dashboard / REST API | 9002 | listen_port + 2 |
| Raft replication | 9500 | listen_port + 500 |
| SWIM gossip | 9600 | listen_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 bydashboard_allowed_cidrs - Metrics — open only when
expose_metrics = true - Raft + Gossip — open when
cluster_enabled = true, gated bycluster_allowed_cidrs
Single Node
Section titled “Single Node”The simplest deployment — one droplet with all defaults:
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 }do_token = "dop_v1_..."ssh_key_name = "my-key"terraform apply -var-file=variables.tfvars
flo --server "$(terraform output -raw listen_endpoint)" kv set hello worldopen "$(terraform output -raw dashboard_url)"3-Node Cluster
Section titled “3-Node Cluster”Provision three droplets wired together via reserved IPs so the gossip seed list is stable from the first apply:
locals { node_ids = [1, 2, 3] listen_port = 9000 gossip_port = local.listen_port + 600}
# Reserve stable IPs for each noderesource "digitalocean_reserved_ip" "node" { for_each = toset([for id in local.node_ids : tostring(id)]) region = "lon1"}
# Build the seed list from those IPslocals { 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 dropletresource "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:
flo --server "$(terraform output -json listen_endpoints | jq -r '."1"')" \ kv set cluster okThe cluster bootstraps automatically — the first node to come up forms the cluster, and the rest join via the seed list.
Configuration Reference
Section titled “Configuration Reference”Required
Section titled “Required”| Variable | Type | Description |
|---|---|---|
ssh_key_ids | list(string) | DigitalOcean SSH key IDs or fingerprints. At least one required. |
Naming & Placement
Section titled “Naming & Placement”| Variable | Type | Default | Description |
|---|---|---|---|
environment | string | "dev" | Label used in droplet name and tags |
region | string | "lon1" | DigitalOcean region slug |
droplet_size | string | "s-2vcpu-4gb" | Droplet size. Flo benefits from multiple vCPUs (one shard per CPU) |
image | string | "ubuntu-24-04-x64" | Base image slug |
tags | list(string) | [] | Extra droplet tags |
project_id | string | "" | Optional DO project ID |
Droplet Features
Section titled “Droplet Features”| Variable | Type | Default | Description |
|---|---|---|---|
enable_backups | bool | false | Weekly droplet backups |
enable_monitoring | bool | true | DO monitoring agent |
enable_ipv6 | bool | true | Enable IPv6 |
Firewall
Section titled “Firewall”| Variable | Type | Default | Description |
|---|---|---|---|
create_firewall | bool | true | Create a DO firewall. Disable if you manage firewalls externally |
ssh_allowed_cidrs | list(string) | ["0.0.0.0/0", "::/0"] | CIDRs allowed to reach SSH |
api_allowed_cidrs | list(string) | ["0.0.0.0/0", "::/0"] | CIDRs allowed to reach the wire-protocol port |
dashboard_allowed_cidrs | list(string) | ["0.0.0.0/0", "::/0"] | CIDRs allowed to reach the dashboard. Restrict in production |
metrics_allowed_cidrs | list(string) | ["0.0.0.0/0", "::/0"] | CIDRs for Prometheus (only when expose_metrics = true) |
expose_metrics | bool | false | Open the metrics port on the firewall |
cluster_allowed_cidrs | list(string) | ["0.0.0.0/0", "::/0"] | CIDRs for Raft and gossip traffic |
extra_inbound_tcp_ports | list(number) | [] | Additional TCP ports to open |
Flo Installation
Section titled “Flo Installation”| Variable | Type | Default | Description |
|---|---|---|---|
flo_version | string | "" | Release tag for install.sh. Empty = latest. Pin in production |
Flo Runtime
Section titled “Flo Runtime”| Variable | Type | Default | Description |
|---|---|---|---|
listen_port | number | 9000 | Wire-protocol port. Metrics = +1, Dashboard = +2 |
bind_address | string | "0.0.0.0" | Bind address for the wire listener |
data_dir | string | "/var/lib/flo" | Data directory. Created and chowned by cloud-init |
shards | number | 0 | Number of shards. 0 = auto-detect from CPU count |
durability | string | "async_flush" | Storage durability: async_flush, sync_flush, or fsync |
hot_buffer_capacity | number | 0 | In-memory hot buffer size in bytes. 0 = default |
log_level | string | "info" | Log level |
enable_metrics | bool | true | Enable Prometheus metrics endpoint |
enable_dashboard | bool | true | Enable dashboard HTTP API + web UI |
dashboard_bind_address | string | "0.0.0.0" | Bind address for the dashboard |
Cluster
Section titled “Cluster”| Variable | Type | Default | Description |
|---|---|---|---|
cluster_enabled | bool | false | Join this droplet to a Flo cluster |
cluster_node_id | number | 0 | Unique node ID within the cluster (1, 2, 3, …) |
cluster_seeds | list(string) | [] | Gossip seed addresses (host:gossip_port) |
Persistent Storage
Section titled “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.
| Variable | Type | Default | Description |
|---|---|---|---|
volume_size | number | 0 | Volume size in GB. 0 keeps data on the root disk |
volume_name | string | "" | Volume name. Empty auto-derives <droplet>-data |
volume_filesystem_type | string | "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}Outputs
Section titled “Outputs”| Output | Description |
|---|---|
droplet_id | DigitalOcean droplet ID |
droplet_name | Droplet name |
ipv4_address | Public IPv4 address |
ipv4_address_private | Private VPC IPv4 address |
ipv6_address | Public IPv6 address (empty if disabled) |
urn | Droplet URN |
listen_endpoint | host:port for the CLI and SDKs |
dashboard_url | Dashboard URL (empty when disabled) |
metrics_endpoint | Prometheus metrics URL (empty when disabled) |
gossip_endpoint | host:gossip_port — use in other nodes’ cluster_seeds |
raft_port | Raft replication port |
gossip_port | SWIM gossip port |
firewall_id | Firewall ID (empty when create_firewall = false) |
volume_id | Volume ID (empty when volume_size = 0) |
volume_name | Volume name (empty when volume_size = 0) |
volume_urn | Volume URN (empty when volume_size = 0) |
Operational Notes
Section titled “Operational Notes”Service management
Section titled “Service management”Flo runs as the flo system user under systemd:
ssh root@$(terraform output -raw ipv4_address)
# View logsjournalctl -u flo -f
# Restartsystemctl restart flo
# Check statussystemctl status floData directory
Section titled “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
Section titled “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.thisResizing
Section titled “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
Section titled “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 IPdashboard_allowed_cidrs = ["203.0.113.5/32"]
# Option 2: Bind to private interface, access via SSH tunneldashboard_bind_address = "10.0.0.2" # private VPC IP
# Then: ssh -L 9002:localhost:9002 root@<ip>Metrics scraping
Section titled “Metrics scraping”The metrics port is not opened on the firewall by default. To scrape Prometheus metrics externally:
expose_metrics = truemetrics_allowed_cidrs = ["10.0.0.0/16"] # only your VPCOr scrape locally via the DigitalOcean monitoring agent (which runs on-loopback).
Upgrading Flo
Section titled “Upgrading Flo”To upgrade Flo on an existing droplet:
# SSH in and run the installer with the new versionssh root@<ip> 'curl -fsSL https://raw.githubusercontent.com/floruntime/flo/master/scripts/install.sh | sh -s -- --version v0.2.0'
# Restartssh 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.thisPublishing to the Terraform Registry
Section titled “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
Section titled “Next Steps”- Configuration reference — understand
flo.toml - Clustering deep-dive — SWIM gossip and Raft consensus internals
- Docker deployment — run Flo with Docker Compose