Part 2 — Provision with Terraform
We’ll use the official runestack/rune/digitalocean module. It does five things in one apply:
- Creates the droplet (Ubuntu 24.04) and a firewall.
- Cloud-init installs Docker +
runed+ theruneCLI. - Optionally SSHes back in, runs
rune admin bootstrap, and writes the token to your laptop. - Pre-loads docker registry credentials so
runedcan pull private images on first start. - (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
Section titled “1. Write the four Terraform files”Put these in infra/terraform/do/.
provider.tf
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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"rune_version = "v0.0.1-dev.22"3. Pre-load GHCR credentials (skip if you’ll only pull public images)
Section titled “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
Section titled “4. Apply”cd infra/terraform/doterraform initterraform applyWhat happens, in order:
- 0:00 — Droplet boots; cloud-init starts installing Docker +
runed. - ~2:00 —
runed.servicestarts, edge mode binds:80/:443, ACME orchestrator registers. - ~2:30 — The module’s
null_resource.bootstrapSSHes in, runsrune admin bootstrap, fetches the token toinfra/terraform/do/rune-admin.token(mode 0600). - 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
Section titled “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
Section titled “What you just provisioned”| Resource | Purpose |
|---|---|
digitalocean_reserved_ip.rune | Stable IPv4 for DNS. Has prevent_destroy = true. |
module.rune.digitalocean_droplet | The actual box. Recreating it is safe — Reserved IP stays. |
module.rune.digitalocean_firewall | Ingress: 22, 80, 443, 7861, 7863. Tighten with ssh_allowed_cidrs later. |
module.rune.null_resource.bootstrap | One-shot SSH + rune admin bootstrap. |
Next: Part 3 — Author your runeset → · Back: Part 1 — Project layout