Skip to content

Getting Started with Terraform

This guide stands up a minimal Frostmoln configuration with Terraform.

1. Configure the provider

Create main.tf:

hcl
terraform {
  required_providers {
    frostmoln = {
      source = "frostmoln/frostmoln"
    }
  }
}

provider "frostmoln" {
  # Credentials are read from provider arguments or environment variables.
  # Generate an API key in the portal under Settings -> API Keys.
}

Provide your API key via the environment so it stays out of source control:

bash
export FROSTMOLN_API_KEY=fmk_...

Selecting a tenant

By default the provider manages resources in your account's default tenant. To target another tenant, set tenant_id on the provider — or the FROSTMOLN_TENANT_ID environment variable:

hcl
provider "frostmoln" {
  tenant_id = "00000000-0000-0000-0000-000000000000"
}

Targeting a tenant other than your default requires an fm CLI / OIDC session (fm auth login) whose user belongs to multiple tenants — an API key is bound to a single tenant. One tenant per provider instance; to manage more than one tenant in the same configuration, declare a second provider with an alias. Don't re-point tenant_id on a provider that already manages resources — use a separate aliased provider (and terraform state mv), or Terraform tries to recreate them in the new tenant.

2. Define resources

The smallest configuration that actually gives you a machine you can log into is a VPC, a gateway for it, a subnet, a security group, an SSH key and the instance itself. Add this to main.tf:

hcl
resource "frostmoln_vpc" "main" {
  name = "tf-vpc"
  cidr = "10.0.0.0/16"
}

# A VPC is an isolated network until a gateway is declared for it: no outbound
# internet and no platform DNS. Leave this out and the instance below still
# boots, but cloud-init cannot reach anything and name resolution fails.
resource "frostmoln_gateway" "main" {
  vpc_id = frostmoln_vpc.main.id
  mode   = "public_ip"
}

resource "frostmoln_subnet" "main" {
  name   = "tf-subnet"
  vpc_id = frostmoln_vpc.main.id
  cidr   = "10.0.1.0/24"
  zone   = "falkenberg"
}

resource "frostmoln_security_group" "web" {
  name   = "tf-web"
  vpc_id = frostmoln_vpc.main.id
}

resource "frostmoln_security_group_rule" "ssh" {
  security_group_id = frostmoln_security_group.web.id
  direction         = "ingress"
  protocol          = "tcp"
  port_range_min    = 22
  port_range_max    = 22
  remote_cidr       = "203.0.113.4/32" # your own address, not 0.0.0.0/0
  description       = "SSH from the office"
}

resource "frostmoln_ssh_key" "laptop" {
  name       = "my-laptop"
  public_key = file(pathexpand("~/.ssh/id_ed25519.pub"))
}

data "frostmoln_image" "ubuntu" {
  name = "ubuntu-24.04"
}

resource "frostmoln_instance" "web" {
  name      = "tf-web"
  image_id  = data.frostmoln_image.ubuntu.id
  flavor_id = "gp1.small"
  zone      = "falkenberg"
  vpc_id    = frostmoln_vpc.main.id
  subnet_id = frostmoln_subnet.main.id

  security_groups = [frostmoln_security_group.web.id]
  ssh_key_names   = [frostmoln_ssh_key.laptop.name]
}

# An address of its own, so you can reach the instance from outside. The
# depends_on is what makes `terraform destroy` work: a gateway refuses to be
# removed while public IPs in its VPC still point at it, and without this edge
# nothing tells Terraform to release the address before the gateway.
resource "frostmoln_public_ip" "web" {
  instance_id = frostmoln_instance.web.id

  depends_on = [frostmoln_gateway.main]
}

output "web_address" {
  value = frostmoln_public_ip.web.address
}

A few things in there are worth knowing before you run it.

  • A VPC has no way to the internet until a gateway is declared for it.frostmoln_vpc carries no connectivity argument on purpose — in Terraform the connectivity choice is the presence or absence of frostmoln_gateway. A VPC without one is isolated in both directions, and because platform DNS and managed-service connectivity are reached over the same path, instances in it cannot resolve names either. That is the usual reason a first terraform apply looks like it worked and then cloud-init fails on every package it tries to install. See Gateway.
  • image_id and flavor_id take IDs, not display names. A flavor's ID is its SKU, so gp1.small can be written literally — see what is on offer with fm compute flavor list, or read it in Terraform with the frostmoln_flavor / frostmoln_flavors data sources. Images are identified by UUID, so look one up by name with the frostmoln_image data source as above.
  • security_groups decides what can reach in. Omit it, or set it to [], and the instance falls back to the tenant's default group, which allows all outbound traffic but admits inbound only from instances in that same group — so nothing from the internet, SSH included. A group you create yourself starts the same way round: outbound allowed, no inbound rules at all, so the inbound you want has to be written out as frostmoln_security_group_rule.ssh does above.
  • ssh_key_names takes key names. frostmoln_ssh_key is identified by its name within a tenant, so frostmoln_ssh_key.laptop.name is the value to pass. Terraform's file() does not expand ~ by itself — wrap the path in pathexpand() as above, or give an absolute path.
  • zone is optional. Leave it out and the platform picks an availability zone and records it in state. Keep the subnet and the instance in the same one.

3. Apply

bash
terraform init
terraform plan
terraform apply

Terraform tracks state, so subsequent apply runs only change what differs.

4. Tear it down

terraform destroy removes the resources again, with three deliberate stops in the way:

  • The gateway refuses to be destroyed until the configuration says the loss of connectivity is intended. Add acknowledge_connectivity_loss = true to frostmoln_gateway.main, terraform apply that, and then destroy — and put the line back only when you next need it, since a true left behind disarms the guard for the life of the resource.
  • The gateway also refuses while public IPs in its VPC still depend on it. The depends_on above settles that for the one address in this configuration, but any other public IP in the same VPC — a load balancer's, or one this configuration does not manage — has to be disassociated first. See Public IPs.
  • Destroying frostmoln_public_ip releases the address for good. It returns to a shared pool and is re-issued to whoever asks next, so an address that is published in DNS or sitting in someone's allow-list is worth protecting with Terraform's own lifecycle { prevent_destroy = true }.