Part 2 — Provision with Terraform

Stand up a Rune droplet on DigitalOcean with a Reserved IP, auto-bootstrap the admin token, and pre-wire a GHCR registry credential.

We'll use the official runestack/rune/digitalocean module. It does five things in one apply:

  1. Creates the droplet (Ubuntu 24.04) and a firewall.
  2. Cloud-init installs Docker + runed + the rune CLI.
  3. Optionally SSHes back in, runs rune admin bootstrap, and writes the token to your laptop.
  4. Pre-loads docker registry credentials so runed can pull private images on first start.
  5. (Below) we wrap it in a Reserved IP so DNS points to a stable address that survives droplet destroys.

For the non-Terraform alternative, see the DigitalOcean deploy guide. For the full module reference, see the Terraform DigitalOcean guide.

1. Write the four Terraform files

Put these in infra/terraform/do/.

provider.tf

terraform {
  required_version = ">= 1.5.0"
 
  required_providers {
    digitalocean = {
      source  = "digitalocean/digitalocean"
      version = ">= 2.40, < 3.0"
    }
  }
}
 
provider "digitalocean" {
  token = var.do_token
}
 
data "digitalocean_ssh_key" "main" {
  name = var.ssh_key_name
}

main.tf

# Reserved IP — survives droplet destroys. DNS points here once, never changes.
resource "digitalocean_reserved_ip" "rune" {
  region = var.region
 
  lifecycle {
    prevent_destroy = true
  }
}
 
resource "digitalocean_reserved_ip_assignment" "rune" {
  ip_address = digitalocean_reserved_ip.rune.ip_address
  droplet_id = module.rune.droplet_id
}
 
module "rune" {
  source  = "runestack/rune/digitalocean"
  version = "0.0.8"
 
  ssh_key_ids = [data.digitalocean_ssh_key.main.id]
 
  node_role    = "edge"          # edge: binds :80/:443, runs ACME
  acme_email   = var.acme_email
  environment  = var.environment
  region       = var.region
  droplet_size = var.droplet_size
  rune_version = var.rune_version
 
  # Pre-load GHCR credentials so first-pull works.
  # `from_secret` + `bootstrap` keeps the PAT out of runefile.toml.
  docker_registries = var.docker_registries
  runed_environment = var.runed_environment
 
  # Auto-bootstrap: SSH in after install, run `rune admin bootstrap`,
  # write the token to ./rune-admin.token on your laptop.
  bootstrap                 = true
  bootstrap_ssh_private_key = file("~/.ssh/id_ed25519")
  bootstrap_token_path      = "rune-admin.token"
  bootstrap_namespace       = var.environment
}

variables.tf

variable "do_token"      { type = string  sensitive = true }
variable "ssh_key_name"  { type = string }
variable "region"        { type = string  default = "lon1" }
variable "droplet_size"  { type = string  default = "s-2vcpu-4gb" }
variable "environment"   { type = string  default = "dev" }
variable "acme_email"    { type = string }
variable "rune_version"  { type = string  default = "v0.0.1-dev.22" }
 
variable "docker_registries" {
  type      = any
  sensitive = true
  default   = []
}
 
variable "runed_environment" {
  type      = map(string)
  sensitive = true
  default   = {}
}

outputs.tf

output "reserved_ip" {
  value = digitalocean_reserved_ip.rune.ip_address
}
 
output "grpc_endpoint" {
  value = "${digitalocean_reserved_ip.rune.ip_address}:7863"
}
 
output "rune_login_command" {
  value = "rune login myapp-${var.environment} --server ${digitalocean_reserved_ip.rune.ip_address}:7863 --token-file ${abspath("rune-admin.token")} --default-namespace ${var.environment}"
}

2. Fill in terraform.tfvars

Create infra/terraform/do/terraform.tfvars (this is gitignored from part 1):

do_token     = "dop_v1_xxxx..."
ssh_key_name = "my-laptop"
 
region       = "lon1"
droplet_size = "s-2vcpu-4gb"
environment  = "dev"
acme_email   = "[email protected]"
rune_version = "v0.0.1-dev.22"

3. Pre-load GHCR credentials (skip if you'll only pull public images)

If your CI will push to GitHub Container Registry, runed needs to authenticate to pull. Pass credentials via env vars so they're never committed:

export TF_VAR_docker_registries='[{
  "name":"ghcr",
  "registry":"ghcr.io",
  "from_secret":"ghcr-credentials",
  "bootstrap":true,
  "data":{"username":"YOUR_GH_USERNAME","password":"${GHCR_PAT}"}
}]'
 
export TF_VAR_runed_environment='{"GHCR_PAT":"ghp_..."}'

On first start runed materialises the ghcr-credentials Secret from the bootstrap data, then resolves ${GHCR_PAT} from /etc/rune/runed.env. The PAT never lands in Terraform state as cleartext. The mechanics are spelled out in the GHCR auth guide.

4. Apply

cd infra/terraform/do
terraform init
terraform apply

What happens, in order:

  1. 0:00 — Droplet boots; cloud-init starts installing Docker + runed.
  2. ~2:00runed.service starts, edge mode binds :80/:443, ACME orchestrator registers.
  3. ~2:30 — The module's null_resource.bootstrap SSHes in, runs rune admin bootstrap, fetches the token to infra/terraform/do/rune-admin.token (mode 0600).
  4. Outputs print:
    reserved_ip        = "134.209.xx.xx"
    grpc_endpoint      = "134.209.xx.xx:7863"
    rune_login_command = "rune login myapp-dev --server ..."

5. Log in from your laptop

eval "$(terraform output -raw rune_login_command)"
rune whoami
# Subject: root  Policies: [root]

You're now an authenticated admin on a fresh server. Save the printed command — you'll need it again to mint the CI service token in part 4.

What you just provisioned

ResourcePurpose
digitalocean_reserved_ip.runeStable IPv4 for DNS. Has prevent_destroy = true.
module.rune.digitalocean_dropletThe actual box. Recreating it is safe — Reserved IP stays.
module.rune.digitalocean_firewallIngress: 22, 80, 443, 7861, 7863. Tighten with ssh_allowed_cidrs later.
module.rune.null_resource.bootstrapOne-shot SSH + rune admin bootstrap.

:::caution[Let's Encrypt rate limits] LE allows 5 certs per exact domain per 7 days. If you destroy + recreate the droplet repeatedly while iterating, ACME will start returning 429 rateLimited. Either use a sub-domain you can rotate, or stage on acme_directory = "https://acme-staging-v02.api.letsencrypt.org/directory" while iterating. :::


Next: Part 3 — Author your runeset → · Back: Part 1 — Project layout