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